Packages

phoenix_kit

1.6.14
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
phoenix_kit lib phoenix_kit_web live modules blogging editor.html.heex
Raw

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] || "" %>
<%!-- Wrapper div to prevent LiveView from re-rendering the warning --%>
<div phx-update="ignore" id="js-warning-wrapper">
<div
id="editor-js-warning"
class="alert alert-warning mb-4 flex items-start gap-3"
role="alert"
>
<.icon name="hero-exclamation-triangle" class="w-5 h-5 shrink-0" />
<div>
<p class="font-semibold text-base-content">
{gettext("Interactive editor features need JavaScript")}
</p>
<p class="text-sm text-base-content/80" data-js-warning-message>
{gettext(
"If you cannot insert components or see unsaved-change prompts, enable JavaScript or allow inline scripts for this page."
)}
</p>
</div>
</div>
</div>
<noscript>
<div class="alert alert-warning mb-4 flex items-start gap-3" role="alert">
<.icon name="hero-exclamation-triangle" class="w-5 h-5 shrink-0" />
<div>
<p class="font-semibold text-base-content">{gettext("JavaScript is disabled")}</p>
<p class="text-sm text-base-content/80">
{gettext(
"Turn on JavaScript to insert components and receive autosave protection in this editor."
)}
</p>
</div>
</div>
</noscript>
<script nonce={nonce}>
(function() {
const warningEl = document.getElementById("editor-js-warning");
if (warningEl) {
warningEl.classList.add("hidden");
}
})();
</script>
<script nonce={nonce}>
(function() {
let warningEl = null;
function getWarningEl() {
if (!warningEl || !document.body.contains(warningEl)) {
warningEl = document.getElementById("editor-js-warning");
}
return warningEl;
}
let initAttempts = 0;
function hideWarning() {
const el = getWarningEl();
if (el && !el.classList.contains("hidden")) {
el.classList.add("hidden");
}
}
function showWarning(message) {
const el = getWarningEl();
if (!el) return;
el.classList.remove("hidden");
if (message) {
const msgEl = el.querySelector("[data-js-warning-message]");
if (msgEl) {
msgEl.textContent = message;
}
}
}
// At this point JS is running, so keep the warning hidden
hideWarning();
let textarea = null;
let lastCursorPosition = 0;
// Initialize when DOM is ready
function initContentEditor() {
textarea = document.getElementById("content-textarea");
if (!textarea) {
initAttempts += 1;
if (initAttempts > 20) {
showWarning("Editor failed to initialize. Please check if inline JavaScript is allowed.");
}
setTimeout(initContentEditor, 100);
return;
}
initAttempts = 0;
hideWarning();
// Track cursor position on blur
textarea.addEventListener("blur", () => {
lastCursorPosition = textarea.selectionStart;
});
// Track cursor position on selection change
textarea.addEventListener("select", () => {
lastCursorPosition = textarea.selectionStart;
});
// Track cursor position on click
textarea.addEventListener("click", () => {
lastCursorPosition = textarea.selectionStart;
});
// Track cursor position on keyup
textarea.addEventListener("keyup", () => {
lastCursorPosition = textarea.selectionStart;
});
}
function insertAtCursor(text) {
if (!textarea) {
return;
}
// Use last known cursor position
const start = lastCursorPosition || 0;
const end = start;
const currentValue = textarea.value;
// Insert text at cursor position
const newValue = currentValue.substring(0, start) + text + currentValue.substring(end);
textarea.value = newValue;
// Move cursor to end of inserted text
const newCursorPos = start + text.length;
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
lastCursorPosition = newCursorPos;
// Focus the textarea
textarea.focus();
// Trigger change event to update LiveView
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
// Listen for custom window events from LiveView
window.addEventListener("phx:insert-at-cursor", (e) => {
insertAtCursor(e.detail.text);
});
window.addEventListener("phx:prompt-and-insert", (e) => {
const { prompt, placeholder } = e.detail;
const url = window.prompt(prompt, placeholder);
if (url && url.trim()) {
// Send back to LiveView via pushEvent
const liveSocket = window.liveSocket;
if (liveSocket) {
const view = liveSocket.getViewByEl(textarea);
if (view) {
view.pushEvent("insert_video_component", { url: url.trim() });
}
}
}
});
// Global function for inserting components (called from onclick)
window.insertComponent = function(text) {
insertAtCursor(text);
};
// Global function for prompting and inserting video
window.promptAndInsertVideo = function() {
const url = window.prompt("Enter YouTube URL:", "https://youtu.be/dQw4w9WgXcQ");
if (url && url.trim()) {
const template = "\n<Video url=\"" + url.trim() + "\">\n Optional caption text\n</Video>\n";
insertAtCursor(template);
}
};
// Global function for inserting image component (called after media selection)
window.insertImageComponent = function(fileId) {
const template = "\n<Image file_id=\"" + fileId + "\" alt=\"Description here\" />\n";
insertAtCursor(template);
};
// Listen for exec-js events from LiveView
window.addEventListener("phx:exec-js", (e) => {
try {
eval(e.detail.js);
} catch (err) {
console.error("[ContentEditor] Error executing JS:", err);
}
});
// Initialize on DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initContentEditor);
} else {
initContentEditor();
}
// Re-initialize after LiveView updates
document.addEventListener("phx:page-loading-stop", () => {
warningEl = null;
initContentEditor();
hideWarning();
});
const warningObserver = new MutationObserver(() => {
const el = getWarningEl();
if (el && !el.classList.contains("hidden")) {
el.classList.add("hidden");
}
});
warningObserver.observe(document.documentElement, { childList: true, subtree: true });
// 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;
}
});
})();
</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">
<%!-- Language Switcher --%>
<%= if length(@all_enabled_languages) > 1 do %>
<div class="flex items-center gap-2" id="language-switcher">
<span class="text-sm text-base-content/70">{gettext("Language")}:</span>
<div class="join">
<%= for lang_code <- @all_enabled_languages do %>
<% lang_info = PhoenixKitWeb.Live.Modules.Blogging.get_language_info(lang_code)
flag = if lang_info, do: lang_info.flag, else: ""
is_current = @current_language == lang_code
file_exists = lang_code in @available_languages %>
<button
type="button"
class={[
"join-item btn btn-sm shadow-none",
cond do
is_current -> "btn-primary"
file_exists -> "btn-outline"
true -> "btn-success btn-outline"
end
]}
phx-click="switch_language"
phx-value-language={lang_code}
title={
cond do
is_current && lang_info -> "Editing #{lang_info.name}"
file_exists && lang_info -> "Switch to #{lang_info.name}"
lang_info -> "Create #{lang_info.name} translation"
true -> lang_code
end
}
>
<%= cond do %>
<% is_current -> %>
<.icon name="hero-check" class="w-4 h-4" />
<% not file_exists -> %>
<.icon name="hero-plus" class="w-3 h-3" />
<% true -> %>
<% end %>
<span class="text-lg phx-click-loading:opacity-50">{flag}</span>
<span class="phx-click-loading:hidden">
{lang_code
|> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
|> String.upcase()}
</span>
<span class="hidden phx-click-loading:inline-block loading loading-spinner loading-xs">
</span>
</button>
<% end %>
</div>
</div>
<% end %>
<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>
<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">
<%= 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>
<%!-- Component Toolbar --%>
<div
class="card bg-base-200 border border-base-300"
onmousedown="event.preventDefault()"
>
<div class="card-body p-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold text-base-content/70 mr-2">
{gettext("Insert Component:")}
</span>
<button
type="button"
phx-click="open_image_component_selector"
class="btn btn-xs btn-outline gap-1"
title={gettext("Insert Image")}
onmousedown="event.preventDefault()"
>
<.icon name="hero-photo" class="w-3 h-3" />
{gettext("Image")}
</button>
<button
type="button"
onclick="window.promptAndInsertVideo()"
class="btn btn-xs btn-outline gap-1"
title={gettext("Insert Video")}
onmousedown="event.preventDefault()"
>
<.icon name="hero-video-camera" class="w-3 h-3" />
{gettext("Video")}
</button>
</div>
</div>
</div>
<.form for={%{}} id="blogging-content" phx-change="update_content" phx-submit="noop">
<textarea
id="content-textarea"
name="content"
phx-debounce="400"
class="textarea textarea-bordered w-full h-[480px] font-mono text-sm leading-6"
>{@content}</textarea>
</.form>
</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>