Packages
phoenix_kit
1.7.9
1.7.208
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/phoenix_kit_web/live/modules/blogging/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/blogging/#{@blog_slug}/edit", locale: @current_locale)
}
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 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.bloggingEditorInsertMedia(null, 'video', url.trim());
}
});
// Function to insert standard markdown media syntax at cursor position
window.bloggingEditorInsertMedia = 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-1" /> {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 Switcher (separate line) --%>
<%= if length(@all_enabled_languages) > 1 do %>
<div class="flex items-center gap-2">
<.blog_language_switcher
languages={
build_editor_languages(
@post,
@blog_slug,
@all_enabled_languages,
@current_language
)
}
current_language={@current_language}
show_status={true}
show_add={true}
on_click="switch_language"
size={:sm}
/>
</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="blogging-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 %>
<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"
placeholder={gettext("auto-generated from first heading")}
title={
gettext(
"Use lowercase letters, numbers, and hyphens only. No spaces or special characters."
)
}
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext("Use lowercase letters, numbers, and hyphens only.")}
</p>
</div>
<% 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 --%>
<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>
</div>
<%!-- Mobile: Always visible buttons --%>
<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>
</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 hover:border-primary hover:bg-primary/5 transition-all group"
>
<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-2">
<.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"
placeholder="018e3c4a-9f6b-7890-abcd-ef1234567890"
/>
<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")}
</span>
</label>
<select
class="select select-bordered w-full"
name="status"
>
<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>
</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"
/>
<p class="text-xs text-base-content/60 mt-1">
{gettext(
"Updating the publication time moves the file to the matching folder."
)}
</p>
</div>
</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-2">
<%!-- 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-warning badge-sm gap-1">
<.icon name="hero-eye" class="w-3 h-3" />
{ngettext(
"1 other editing",
"%{count} others editing",
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>
<% true -> %>
<span class="badge badge-success badge-sm gap-1">
<.icon name="hero-check" class="w-3 h-3" />
{gettext("Saved")}
</span>
<% end %>
<button
type="button"
phx-click="save"
class={[
"btn btn-primary btn-xs shadow-none gap-1",
(@is_autosaving || !@has_pending_changes) &&
"btn-disabled pointer-events-none opacity-60"
]}
disabled={@is_autosaving || !@has_pending_changes}
>
<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={true}
protect_navigation={false}
script_nonce={nonce}
readonly={false}
/>
</div>
</div>
</div>
</div>
</div>
<%!-- 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>