Packages
phoenix_kit
1.7.31
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/#{@blog_slug}/edit")}
project_title={@project_title}
current_locale={@current_locale}
>
<% nonce = assigns[:script_csp_nonce] || assigns[:csp_nonce] || "" %>
<%!-- Unsaved changes tracking and navigation protection --%>
<script nonce={nonce}>
(function() {
// Track unsaved changes state
window.editorUnsavedChanges = false;
// Browser exit protection
window.addEventListener("beforeunload", function(e) {
if (window.editorUnsavedChanges) {
e.preventDefault();
e.returnValue = "";
return "";
}
});
// Intercept navigation links
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");
// Don't block external links or hash links
if (href && !href.startsWith("http") && !href.startsWith("#")) {
e.preventDefault();
e.stopPropagation();
document.getElementById("confirm-cancel-btn").click();
}
}
}
}, 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 input event to update LiveView
textarea.dispatchEvent(new Event("input", { 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 items-center justify-between">
<button type="button" class="btn btn-outline btn-sm shadow-none" phx-click="back_to_list">
<.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> {gettext("Back to %{blog}",
blog: @blog_name || gettext("Blog")
)}
</button>
<div class="flex items-center gap-3">
<button
type="button"
class="btn btn-outline btn-sm shadow-none"
phx-click="preview"
>
<.icon name="hero-eye" class="w-4 h-4 mr-1" /> {gettext("Preview")}
</button>
<%= if @form["status"] == "published" && @public_url do %>
<a
href={if @has_pending_changes, do: "#", else: @public_url}
target="_blank"
class={[
"btn btn-outline 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 mr-1" /> View Public
</a>
<% end %>
</div>
</div>
<%!-- Language and Version Switchers --%>
<div class="flex flex-wrap items-center gap-4">
<%!-- 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,
@blog_slug,
@all_enabled_languages,
@current_language
) %>
<% primary_lang = Enum.find(all_languages, & &1.is_primary) %>
<% other_languages = Enum.reject(all_languages, & &1.is_primary) %>
<div class="flex flex-col gap-2">
<%!-- Other translations --%>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-base-content/60">{gettext("Language:")}</span>
<.publishing_language_switcher
languages={other_languages}
current_language={@current_language}
show_status={true}
show_add={true}
on_click="switch_language"
size={:sm}
/>
</div>
<%!-- Primary language (on its own line, prominent) --%>
<%= if primary_lang do %>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-base-content/60">{gettext("Primary:")}</span>
<button
type="button"
phx-click="switch_language"
phx-value-language={primary_lang.code}
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 %>
</div>
<% end %>
<%!-- Version Switcher (for versioned posts in both slug and timestamp modes) --%>
<%= if !@is_new_post && @post && !Map.get(@post, :is_legacy_structure, true) do %>
<div class="flex items-center gap-1.5">
<%= 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 @readonly?, do: "btn-disabled opacity-60"}"}
phx-click="open_new_version_modal"
disabled={@readonly?}
>
<.icon name="hero-plus" class="w-3 h-3" />
{gettext("New Version")}
</button>
</div>
<% end %>
</div>
<%!-- Version locking banner removed - with variant versioning all versions are editable --%>
<%!-- Spectator mode banner - someone else is editing --%>
<%= if @readonly? do %>
<div class="alert alert-warning shadow-sm">
<.icon name="hero-eye" class="w-5 h-5" />
<div class="flex-1">
<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>
</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 file:")}</span>
<span>
{gettext(
"This file (%{code}.phk) 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 %>
<%!-- Legacy structure migration banner --%>
<%= if @post && @post.is_legacy_structure do %>
<div class="alert alert-info shadow-sm">
<.icon name="hero-arrow-up-circle" class="w-5 h-5" />
<div class="flex-1">
<span class="font-medium">{gettext("Legacy file structure detected:")}</span>
<span>
{gettext(
"This post uses the legacy flat file structure. Migrate to enable versioning features like version history and editing published content safely."
)}
</span>
</div>
<button
type="button"
class="btn btn-sm btn-primary"
phx-click="migrate_to_versioned"
phx-disable-with={gettext("Migrating...")}
>
<.icon name="hero-arrow-up-circle" class="w-4 h-4" />
{gettext("Migrate to v1")}
</button>
</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 %>
<%!-- Primary Language Indicator (only shown on primary language when no update needed) --%>
<%= if @is_primary_language and not @is_new_post and length(@all_enabled_languages) > 1 and not needs_update do %>
<div class="flex items-center gap-3 px-4 py-2.5 rounded-lg border bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/20">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white shadow-sm bg-gradient-to-br from-amber-400 to-orange-500">
<.icon name="hero-star-solid" class="w-4 h-4" />
</div>
<div class="flex-1">
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">
{gettext("Primary Language")}
</span>
<p class="text-xs text-base-content/60">
{gettext("Other languages inherit publication status and defaults from this version.")}
</p>
</div>
</div>
<% end %>
<%!-- AI Translation Section for Non-Primary Languages (hidden for readonly spectators) --%>
<%= if @ai_enabled and not @is_primary_language and not @is_new_post and length(@ai_endpoints) > 0 and not @readonly? do %>
<div class="collapse collapse-arrow bg-base-100 shadow-xl border border-base-200">
<input
type="checkbox"
checked={@show_ai_translation}
phx-click="toggle_ai_translation"
/>
<div class="collapse-title">
<div class="flex items-center gap-1.5">
<.icon name="hero-language" class="w-5 h-5 text-primary" />
<span class="font-semibold">{gettext("AI Translation")}</span>
</div>
</div>
<div class="collapse-content">
<div class="pt-2 space-y-4">
<p class="text-sm text-base-content/70">
{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
)}
</p>
<%!-- Endpoint Selection --%>
<form phx-change="select_ai_endpoint">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium">{gettext("Translate with")}</span>
<select
class="select select-bordered select-sm"
name="endpoint_id"
>
<option value="">{gettext("Select an endpoint...")}</option>
<%= for {id, name} <- @ai_endpoints do %>
<option value={id} selected={@ai_selected_endpoint_id == id}>
{name}
</option>
<% end %>
</select>
</div>
<p class="text-xs text-base-content/50 mt-1.5">
{gettext("Configure endpoints in")}
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/ai")}
class="link link-primary"
>
{gettext("AI Settings")}
</.link>
</p>
</form>
<%!-- 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">
{@ai_translation_progress || 0} / {@ai_translation_total || 0}
</span>
</div>
<progress
class={[
"progress w-full",
@ai_translation_status == :completed && "progress-success",
@ai_translation_status != :completed && "progress-primary"
]}
value={@ai_translation_progress || @ai_translation_total || 0}
max={@ai_translation_total || 1}
>
</progress>
</div>
<% end %>
<%!-- Action Button --%>
<div class="flex flex-wrap gap-3">
<button
type="button"
class={"btn btn-primary btn-sm #{if @ai_selected_endpoint_id == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_to_this_language"
disabled={
@ai_selected_endpoint_id == nil or
@ai_translation_status in [:enqueued, :in_progress]
}
>
<.icon name="hero-language" class="w-4 h-4" />
{gettext("Translate to This Language")}
</button>
</div>
<%!-- Info --%>
<div class="text-xs text-base-content/50">
<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>
</div>
</div>
</div>
</div>
<% end %>
<%!-- AI Translation Section (Primary Language) --%>
<%= if @ai_enabled and @is_primary_language and not @is_new_post and length(@ai_endpoints) > 0 do %>
<div class="collapse collapse-arrow bg-base-100 shadow-xl border border-base-200">
<input
type="checkbox"
checked={@show_ai_translation}
phx-click="toggle_ai_translation"
/>
<div class="collapse-title">
<div class="flex items-center gap-1.5">
<.icon name="hero-language" class="w-5 h-5 text-primary" />
<span class="font-semibold">{gettext("AI Translation")}</span>
</div>
</div>
<div class="collapse-content">
<div class="pt-2 space-y-4">
<p class="text-sm text-base-content/70">
{gettext(
"Automatically translate this post to other languages using AI. The translation will be queued as a background job."
)}
</p>
<%!-- Endpoint Selection --%>
<form phx-change="select_ai_endpoint">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium">{gettext("Translate with")}</span>
<select
class="select select-bordered select-sm"
name="endpoint_id"
>
<option value="">{gettext("Select an endpoint...")}</option>
<%= for {id, name} <- @ai_endpoints do %>
<option value={id} selected={@ai_selected_endpoint_id == id}>
{name}
</option>
<% end %>
</select>
</div>
<p class="text-xs text-base-content/50 mt-1.5">
{gettext("Configure endpoints in")}
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin/ai")}
class="link link-primary"
>
{gettext("AI Settings")}
</.link>
</p>
</form>
<%!-- 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">
{@ai_translation_progress || 0} / {@ai_translation_total || 0}
</span>
</div>
<progress
class={[
"progress w-full",
@ai_translation_status == :completed && "progress-success",
@ai_translation_status != :completed && "progress-primary"
]}
value={@ai_translation_progress || @ai_translation_total || 0}
max={@ai_translation_total || 1}
>
</progress>
</div>
<% end %>
<%!-- Action Buttons --%>
<div class="flex flex-wrap gap-3">
<button
type="button"
class={"btn btn-primary btn-sm #{if @ai_selected_endpoint_id == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_to_all_languages"
disabled={
@ai_selected_endpoint_id == 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_id == nil or @ai_translation_status in [:enqueued, :in_progress], do: "btn-disabled"}"}
phx-click="translate_missing_languages"
disabled={
@ai_selected_endpoint_id == nil or
@ai_translation_status in [:enqueued, :in_progress]
}
>
<.icon name="hero-plus" class="w-4 h-4" />
{gettext("Translate Missing Only")}
</button>
</div>
<%!-- Language Info --%>
<div class="text-xs text-base-content/50 space-y-1">
<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>
</div>
</div>
</div>
</div>
<% end %>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body space-y-6">
<div class="flex flex-col lg:flex-row gap-6">
<div class="lg:w-80 space-y-4">
<.form for={@form} id="publishing-meta" phx-change="update_meta" phx-submit="noop">
<div class="space-y-4">
<%!-- Title is extracted from markdown content (first # Heading) --%>
<%= if @blog_mode == "slug" do %>
<%= if @is_primary_language do %>
<%!-- Primary language: editable slug that sets the directory name --%>
<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 @readonly? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
placeholder={gettext("auto-generated from first heading")}
title={
gettext(
"Use lowercase letters, numbers, and hyphens only. No spaces or special characters."
)
}
readonly={@readonly? 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 @readonly? 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={@readonly? 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: @blog_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_id"]) 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 (@readonly? 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 (@readonly? 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 @readonly? or @viewing_older_version, do: "opacity-50 cursor-not-allowed", else: "hover:border-primary hover:bg-primary/5"}"}
disabled={@readonly? 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 File ID")}
</span>
</div>
</summary>
<div class="px-3 pb-3 pt-2">
<input
type="text"
name="featured_image_id"
value={@form["featured_image_id"]}
class={"input input-bordered input-sm w-full font-mono text-xs #{if @readonly? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
placeholder="018e3c4a-9f6b-7890-abcd-ef1234567890"
readonly={@readonly? 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>
<div>
<label class="label">
<span class="label-text text-sm font-semibold text-base-content">
{gettext("Status")}
<%= if not @is_primary_language and @form["status"] != "published" do %>
<span class="text-xs font-normal text-base-content/60 ml-2">
{gettext("(follows primary language)")}
</span>
<% end %>
</span>
</label>
<select
class={"select select-bordered w-full #{if @readonly? or (not @is_primary_language and @form["status"] != "published"), do: "select-disabled bg-base-200"}"}
name="status"
disabled={
@readonly? or (not @is_primary_language and @form["status"] != "published")
}
>
<%= if @viewing_older_version do %>
<%!-- Older versions can only be published or archived, not draft --%>
<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>
</div>
<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 @readonly? or @viewing_older_version, do: "input-disabled bg-base-200"}"}
readonly={@readonly? or @viewing_older_version}
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Updating the publication time moves the file to the matching folder."
)}
</p>
</div>
<%!-- Version Access Toggle - DISABLED: Boss doesn't want public version history
<% is_versioned_post = @post && not Map.get(@post, :is_legacy_structure, true) %>
<% can_toggle_version_access =
is_versioned_post && @is_primary_language && not @viewing_older_version %>
<%= if can_toggle_version_access do %>
<div class="border-t border-base-200 pt-4 mt-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<span class="text-sm font-semibold text-base-content">
{gettext("Show Version History")}
</span>
<p class="text-xs text-base-content/60">
{gettext("Display a version dropdown on the public post")}
</p>
</div>
<label class={"swap #{if @readonly?, do: "opacity-50 pointer-events-none"}"}>
<input
type="checkbox"
checked={Map.get(@post.metadata, :allow_version_access, false)}
phx-click="toggle_version_access"
phx-value-enabled={
if Map.get(@post.metadata, :allow_version_access, false),
do: "false",
else: "true"
}
disabled={@readonly?}
/>
<span class="swap-on">
<span class="badge badge-success badge-sm">{gettext("On")}</span>
</span>
<span class="swap-off">
<span class="badge badge-ghost badge-sm">{gettext("Off")}</span>
</span>
</label>
</div>
</div>
<% end %>
--%>
</div>
</.form>
</div>
<div class="flex-1 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold text-base-content">{gettext("Content")}</h2>
<div class="flex items-center 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">{gettext("Unsaved changes")}</span>
<% @is_new_post -> %>
<span class="badge badge-ghost badge-sm">{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 =
@readonly? || @is_autosaving ||
(!@has_pending_changes && !@is_new_post && !@is_new_translation) %>
<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>
</div>
<%!-- 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 (@readonly? or @viewing_older_version)}
protect_navigation={false}
script_nonce={nonce}
readonly={@readonly? or @viewing_older_version}
/>
</div>
</div>
</div>
</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",
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_ids={@media_selected_ids}
phoenix_kit_current_user={assigns[:phoenix_kit_current_user]}
/>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>