Packages
phoenix_kit
1.7.33
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/posts/web/edit.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={@page_title}
current_path={@url_path}
project_title={@project_title}
>
<div class="p-6">
<%!-- Back Button --%>
<.link
navigate={Routes.path("/admin/posts")}
class="btn btn-outline btn-sm mb-4"
>
<.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Back to Posts
</.link>
<.form
for={@form}
id="post-form"
phx-change="validate"
phx-submit="save"
>
<%!-- Main Layout: 2/3 Content + 1/3 Sidebar --%>
<div class="flex flex-col lg:flex-row gap-6">
<%!-- Left Column: Main Content (2/3) --%>
<div class="flex-[2] flex flex-col gap-6">
<%!-- Basic Information Card --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
<.icon name="hero-document-text" class="w-5 h-5" /> Basic Information
</h2>
<div class="space-y-4">
<%!-- Title --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Title *</span>
<span class="label-text-alt">
{String.length(@form[:title].value || "")}/{@max_title_length}
</span>
</label>
<.input
field={@form[:title]}
type="text"
placeholder="Enter post title..."
maxlength={@max_title_length}
required
/>
</div>
<%!-- Subtitle --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Subtitle / Tagline</span>
<span class="label-text-alt">
{String.length(@form[:sub_title].value || "")}/{@max_subtitle_length}
</span>
</label>
<.input
field={@form[:sub_title]}
type="text"
placeholder="Optional subtitle or tagline..."
maxlength={@max_subtitle_length}
/>
</div>
<%!-- Media --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Media</span>
<span class="label-text-alt">{length(@post_images)}</span>
</label>
<%= if @post_images == [] do %>
<%!-- Full-width add button when empty --%>
<button
type="button"
phx-click="open_featured_image_selector"
class="w-full h-32 border-2 border-dashed border-base-300 rounded-lg hover:border-primary hover:bg-base-200 transition-all cursor-pointer flex flex-col items-center justify-center gap-2 text-base-content/60 hover:text-primary"
>
<.icon name="hero-photo" class="w-8 h-8" />
<span class="text-sm font-medium">Add Media</span>
</button>
<% else %>
<.draggable_list
id="post-images-grid"
items={@post_images}
item_id={fn media -> media.file_id end}
on_reorder="reorder_post_images"
cols={4}
>
<:item :let={media}>
<div class="relative group aspect-square">
<img
src={
PhoenixKit.Modules.Storage.URLSigner.signed_url(
media.file_id,
"thumbnail"
)
}
alt="Post image"
class="w-full h-full object-cover rounded-lg border-2 border-base-300"
/>
<%!-- Featured badge on first image --%>
<%= if media.position == 1 do %>
<div class="absolute top-1 left-1 badge badge-primary badge-xs">
Featured
</div>
<% end %>
<%!-- Remove button --%>
<button
type="button"
phx-click="remove_post_image"
phx-value-id={media.id || media.file_id}
class="absolute top-1 right-1 btn btn-xs btn-circle btn-error opacity-0 group-hover:opacity-100 transition-opacity"
>
<.icon name="hero-x-mark" class="w-3 h-3" />
</button>
</div>
</:item>
<:add_button>
<button
type="button"
phx-click="open_featured_image_selector"
class="w-full aspect-square border-2 border-dashed border-base-300 rounded-lg hover:border-primary hover:bg-base-200 transition-all cursor-pointer flex flex-col items-center justify-center gap-1 text-base-content/60 hover:text-primary"
>
<.icon name="hero-plus" class="w-6 h-6" />
<span class="text-xs font-medium">Add</span>
</button>
</:add_button>
</.draggable_list>
<% end %>
<label class="label">
<span class="label-text-alt text-base-content/70">
Drag to reorder. First image is the featured image.
</span>
</label>
</div>
<%!-- Content --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Content *</span>
<span class="label-text-alt">
{String.length(@content)}/{@max_content_length}
</span>
</label>
<.live_component
module={PhoenixKitWeb.Components.Core.MarkdownEditor}
id="post-content-editor"
content={@content}
placeholder="Write your post content here... Use #hashtags for auto-tagging"
height="400px"
debounce={400}
toolbar={[:image, :video]}
protect_navigation={false}
script_nonce={assigns[:csp_nonce] || ""}
/>
<label class="label">
<span class="label-text-alt text-base-content/70">
Use #hashtags in your content for automatic tag extraction
</span>
</label>
</div>
<%!-- SEO Slug --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">SEO Slug</span>
</label>
<.input
field={@form[:slug]}
type="text"
placeholder={
if @seo_auto_slug,
do: "Auto-generated from title...",
else: "enter-url-slug"
}
disabled={@seo_auto_slug}
/>
<label class="label">
<span class="label-text-alt text-base-content/70">
<%= if @seo_auto_slug do %>
Auto-generation enabled. URL slug will be created from title.
<% else %>
URL-friendly identifier (e.g., my-awesome-post)
<% end %>
</span>
</label>
</div>
</div>
</div>
</div>
<%!-- Groups Card (if enabled) --%>
<%= if @allow_groups do %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
<.icon name="hero-folder" class="w-5 h-5" /> Collections / Groups
</h2>
<%= if length(@user_groups) > 0 do %>
<div class="form-control">
<div class="space-y-2">
<%= for group <- @user_groups do %>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="post[group_ids][]"
value={group.id}
checked={group.id in @selected_groups}
class="checkbox checkbox-primary"
/>
<span class="label-text">
<span class="font-semibold">{group.name}</span>
<%= if group.description do %>
<span class="text-sm text-base-content/60">
- {group.description}
</span>
<% end %>
</span>
</label>
<% end %>
</div>
<label class="label">
<span class="label-text-alt text-base-content/70">
Organize posts into your personal collections
</span>
</label>
</div>
<% else %>
<div class="text-sm text-base-content/60">
No groups created yet.
<.link navigate={Routes.path("/admin/posts/groups")} class="link link-primary">
Create your first group
</.link>
to organize your posts.
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Tags Card --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
<.icon name="hero-hashtag" class="w-5 h-5" /> Tags
</h2>
<%!-- Selected Tags Display --%>
<%= if length(@selected_tags) > 0 do %>
<div class="flex flex-wrap gap-2 mb-4">
<%= for tag <- @selected_tags do %>
<div class="badge badge-primary gap-2">
#{tag}
<button
type="button"
phx-click="remove_tag"
phx-value-tag={tag}
class="btn btn-ghost btn-xs p-0"
>
✕
</button>
</div>
<% end %>
</div>
<% else %>
<div class="text-sm text-base-content/60 mb-4">
No tags added yet. Tags will be automatically extracted from #hashtags in your content.
</div>
<% end %>
<%!-- Manual Tag Input --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Add Tag Manually</span>
<span class="label-text-alt">
{length(@selected_tags)}/{@max_tags} tags
</span>
</label>
<div class="input-group">
<span class="bg-base-200 px-4 py-3">#</span>
<input
type="text"
id="tag-input"
placeholder="Enter tag and press Enter"
class="input input-bordered w-full"
phx-keydown="add_tag"
phx-key="Enter"
phx-value-tag=""
/>
</div>
<label class="label">
<span class="label-text-alt text-base-content/70">
Tags are automatically extracted from #hashtags in content
</span>
</label>
</div>
</div>
</div>
</div>
<%!-- Right Column: Sidebar (1/3) --%>
<div class="flex-1 flex flex-col gap-6 lg:sticky lg:top-6 lg:self-start">
<%!-- Post Info Card (only for existing posts) --%>
<%= if @post.id do %>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
<.icon name="hero-information-circle" class="w-5 h-5" /> Post Info
</h2>
<div class="space-y-3">
<%!-- Created By --%>
<div class="flex items-center gap-2 text-sm">
<.icon name="hero-user-circle" class="w-4 h-4 text-base-content/50" />
<span class="text-base-content/70">Created by:</span>
<span class="font-medium">
<%= if @post.user do %>
{@post.user.email}
<% else %>
Unknown
<% end %>
</span>
</div>
<%!-- Created When --%>
<div class="flex items-center gap-2 text-sm">
<.icon name="hero-calendar" class="w-4 h-4 text-base-content/50" />
<span class="text-base-content/70">Created:</span>
<span class="font-medium">
{Calendar.strftime(@post.inserted_at, "%B %d, %Y at %H:%M")}
</span>
</div>
<%!-- Public Link (only for public posts with a slug) --%>
<%= if @post.status == "public" && @post.slug && @post.slug != "" do %>
<div class="flex items-start gap-2 text-sm">
<.icon name="hero-link" class="w-4 h-4 text-base-content/50 mt-0.5" />
<div class="flex-1 min-w-0">
<span class="text-base-content/70">Public URL:</span>
<a
href={"/posts/#{@post.slug}"}
target="_blank"
rel="noopener"
class="block text-primary hover:underline truncate font-mono text-xs mt-1"
title={"/posts/#{@post.slug}"}
>
/posts/{@post.slug}
</a>
</div>
</div>
<% else %>
<div class="flex items-center gap-2 text-sm text-base-content/50">
<.icon name="hero-link" class="w-4 h-4" />
<span class="italic">Publish to get a public URL</span>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
<%!-- Post Settings Card --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">
<.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Post Settings
</h2>
<div class="space-y-4">
<%!-- Type --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Post Type</span>
</label>
<.select
field={@form[:type]}
options={[
{"Post (Full content)", "post"},
{"Snippet (Short update)", "snippet"},
{"Repost (Share existing)", "repost"}
]}
/>
<label class="label">
<span class="label-text-alt text-base-content/70">
Different types have different display layouts
</span>
</label>
</div>
<%!-- Status --%>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<.select
field={@form[:status]}
options={[
{"Draft", "draft"},
{"Public", "public"},
{"Unlisted", "unlisted"},
{"Scheduled", "scheduled"}
]}
/>
<label class="label">
<span class="label-text-alt text-base-content/70">
Control post visibility
</span>
</label>
</div>
<%!-- Scheduled Date (only show if status is scheduled) --%>
<%= if @allow_scheduling and @form[:status].value == "scheduled" do %>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Scheduled Publish Time</span>
</label>
<.input
field={@form[:scheduled_at]}
type="datetime-local"
required={@form[:status].value == "scheduled"}
/>
<label class="label">
<span class="label-text-alt text-base-content/70">
Post will be published at this time
</span>
</label>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Action Buttons Card --%>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col gap-3">
<button
type="submit"
class="btn btn-primary w-full"
phx-disable-with="Saving..."
>
<.icon name="hero-check" class="w-4 h-4" />
{if @post.id, do: "Update Post", else: "Create Post"}
</button>
<button
type="button"
phx-click="cancel"
class="btn btn-ghost btn-outline w-full"
>
<.icon name="hero-x-mark" class="w-4 h-4" /> Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</.form>
</div>
<%!-- Media Selector Modal --%>
<.live_component
:if={@show_media_selector}
module={PhoenixKitWeb.Live.Components.MediaSelectorModal}
id="post-media-selector"
show={@show_media_selector}
mode={@media_selector_mode}
selected_ids={[]}
phoenix_kit_current_user={assigns[:phoenix_kit_current_user]}
/>
<script>
// Function to insert standard markdown media syntax at cursor position
window.postsEditorInsertMedia = function(fileUrl, mediaType) {
const textarea = document.getElementById('post-content-editor-textarea');
if (!textarea) return;
// Build standard markdown syntax: 
const altText = mediaType === 'image' ? 'Image description' : 'Video';
const template = '';
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
const currentValue = textarea.value;
const newValue = currentValue.substring(0, start) + template + currentValue.substring(end);
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
textarea.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true }));
};
// Handle exec-js event from server
window.addEventListener("phx:exec-js", (e) => {
if (e.detail && e.detail.js) {
eval(e.detail.js);
}
});
</script>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>