Packages
phoenix_kit
1.6.16
1.7.211
1.7.210
1.7.209
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/blog.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={@page_title}
current_path={@current_path}
project_title={@project_title}
current_locale={@current_locale}
>
<style>
/* Firefox */
.scroll-fade-scroller {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
}
/* Force scrollbar to always show */
.scroll-fade-scroller {
overflow-x: scroll !important;
}
/* WebKit browsers (Chrome, Safari, Edge) */
.scroll-fade-scroller::-webkit-scrollbar {
height: 8px;
display: block !important;
}
.scroll-fade-scroller::-webkit-scrollbar-track {
background: oklch(var(--b2));
border-radius: 4px;
}
.scroll-fade-scroller::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.3);
border-radius: 4px;
}
.scroll-fade-scroller::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.5);
}
/* Additional Firefox scrollbar styling */
@supports (scrollbar-width: thin) {
.scroll-fade-scroller {
scrollbar-width: thin;
scrollbar-color: oklch(var(--bc) / 0.3) oklch(var(--b2));
}
}
</style>
<div class="container flex-col mx-auto px-4 py-6">
<%!-- Header Section --%>
<header class="w-full relative mb-6">
<%!-- Back Button (Left aligned) --%>
<.link
navigate={Routes.path("/admin/blogging", locale: @current_locale)}
class="btn btn-outline btn-primary btn-sm absolute left-0 top-0 -mb-12"
>
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Blogging
</.link>
<%!-- Title Section --%>
<div class="text-center">
<h1 class="text-4xl font-bold text-base-content mb-3">
{(@current_blog && @current_blog["name"]) || @blog_slug || gettext("Blog")}
</h1>
<% blog_slug = (@current_blog && @current_blog["slug"]) || @blog_slug || "your-blog" %>
<% url_prefix =
PhoenixKit.Config.get_url_prefix()
|> case do
"/" -> ""
prefix -> prefix
end %>
<% files_hint = "priv/blogging/" <> blog_slug <> "/…" %>
<% public_url =
if length(@enabled_languages) == 1 do
@endpoint_url <> url_prefix <> "/" <> blog_slug
else
@endpoint_url <> url_prefix <> "/#{@current_locale}/" <> blog_slug
end %>
<div class="text-sm text-base-content/60 space-y-1">
<p>
<span class="font-medium text-base-content">{gettext("Files")}: </span>
<code class="font-mono break-all">{files_hint}</code>
</p>
<p>
<span class="font-medium text-base-content">{gettext("Public URL")}: </span>
<a
href={public_url}
target="_blank"
class="link link-hover font-mono break-all text-xs"
>
{public_url}
</a>
</p>
</div>
</div>
</header>
<div class="space-y-6">
<div class="flex-1">
<div class="flex flex-col md:flex-row gap-3 md:items-center md:justify-between mb-4">
<span class="text-sm text-base-content/70">
{gettext("Showing %{count} posts", count: length(@posts))}
</span>
<div class="flex gap-2">
<button type="button" class="btn btn-outline btn-sm shadow-none" phx-click="refresh">
<.icon name="hero-arrow-path" class="w-4 h-4 mr-1" /> {gettext("Refresh")}
</button>
<button type="button" class="btn btn-primary btn-sm" phx-click="create_post">
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> {gettext("Create Post")}
</button>
</div>
</div>
<%= if @posts == [] do %>
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body">
<h3 class="text-lg font-semibold text-base-content">{gettext("No posts yet")}</h3>
<p class="text-sm text-base-content/70">
{gettext("Create your first post to start sharing content.")}
</p>
</div>
</div>
<% else %>
<div class="grid gap-4">
<%= for post <- @posts do %>
<% blog_slug =
post.blog || (@current_blog && @current_blog["slug"]) || @blog_slug ||
"blog" %>
<% default_lang = List.first(post.available_languages) || @current_locale %>
<% default_lang_path =
Path.join([
Path.dirname(post.path),
"#{default_lang}.phk"
]) %>
<% files_path = "priv/blogging/" <> post.path %>
<% public_url =
BlogHTML.build_post_url(blog_slug, post, post.language || @current_locale) %>
<% full_public_url = @endpoint_url <> public_url %>
<div class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<div class="card-body p-4 md:p-5 min-w-0">
<%!-- Post info section --%>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs uppercase tracking-wide text-base-content/60">
<span>
{format_datetime(post, @phoenix_kit_current_user, @date_time_settings)}
</span>
</div>
<h3 class="text-xl font-semibold text-base-content mt-1 truncate">
<.link
navigate={
PhoenixKit.Utils.Routes.path(
"/admin/blogging/#{blog_slug}/edit?path=#{URI.encode(default_lang_path)}",
locale: @current_locale
)
}
class="hover:text-primary transition-colors"
>
{post.metadata.title || gettext("Untitled post")}
</.link>
</h3>
<p class="text-xs text-base-content/50 mt-1">
<span class="font-medium text-base-content">{gettext("Files")}: </span>
<code class="font-mono break-all">{files_path}</code>
</p>
<p class="text-xs text-base-content/50">
<span class="font-medium text-base-content">{gettext("Public URL")}: </span>
<a
href={full_public_url}
target="_blank"
class="link link-hover font-mono break-all text-xs"
>
{full_public_url}
</a>
</p>
</div>
<%!-- Languages section - separate row with horizontal scroll --%>
<div class="border-t border-base-200 pt-3 mt-3 min-w-0">
<div class="relative scroll-fade-wrapper overflow-hidden">
<%!-- Scroll fade indicators - hidden by default, shown via JS when scrollable --%>
<div class="scroll-fade-indicator-left pointer-events-none absolute left-0 top-0 bottom-0 w-16 bg-gradient-to-r from-base-100 to-transparent z-10 opacity-0 transition-opacity duration-200">
</div>
<div class="scroll-fade-indicator-right pointer-events-none absolute right-0 top-0 bottom-0 w-16 bg-gradient-to-l from-base-100 to-transparent z-10 opacity-0 transition-opacity duration-200">
</div>
<div class="scroll-fade-scroller overflow-x-auto pb-2 -mx-4 px-4 md:-mx-5 md:px-5 scroll-smooth">
<div class="flex flex-nowrap gap-2 items-start w-max">
<%!-- Show buttons for all languages, ordered: primary first, then with content, then without --%>
<% primary_language = List.first(@enabled_languages) || "en"
# Languages with content for this post (excluding primary)
languages_with_content =
post.available_languages
|> Enum.reject(&(&1 == primary_language))
|> Enum.sort()
# Enabled languages without content (excluding primary)
languages_without_content =
@enabled_languages
|> Enum.reject(&(&1 in post.available_languages))
|> Enum.reject(&(&1 == primary_language))
|> Enum.sort()
# Final order: primary first, then with content, then without
all_languages =
[primary_language] ++
languages_with_content ++ languages_without_content
# Only hide flags if: single language enabled AND post has only 1 language file
hide_flags =
length(@enabled_languages) == 1 and
length(post.available_languages) == 1 %>
<%= for lang_code <- all_languages do %>
<% lang_path =
Path.join([
Path.dirname(post.path),
"#{lang_code}.phk"
])
lang_info =
PhoenixKitWeb.Live.Modules.Blogging.get_language_info(lang_code)
flag = if lang_info, do: lang_info.flag, else: ""
file_exists = lang_code in post.available_languages
is_enabled = lang_code in @enabled_languages
# Read language-specific metadata for status
lang_metadata =
if file_exists do
case PhoenixKitWeb.Live.Modules.Blogging.read_post(
@blog_slug,
lang_path
) do
{:ok, lang_post} -> lang_post.metadata
_ -> nil
end
else
nil
end %>
<%= if file_exists do %>
<%!-- Language card with 3 layers of buttons --%>
<div class="flex flex-col gap-1 min-w-[95px]">
<%!-- Layer 1: Status dropdown (only for enabled languages) --%>
<%= if lang_metadata && is_enabled do %>
<div class="dropdown dropdown-end">
<label
tabindex="0"
class={[
"btn btn-xs btn-outline shadow-none w-full justify-start",
case lang_metadata.status do
"published" -> "btn-success"
"archived" -> "btn-neutral"
_ -> "btn-warning"
end
]}
>
{String.capitalize(lang_metadata.status)}
<.icon name="hero-chevron-down" class="w-3 h-3 ml-auto" />
</label>
<ul
tabindex="0"
class="dropdown-content menu p-1 shadow-lg bg-base-100 rounded-box w-32 border border-base-300 z-10"
>
<li>
<button
type="button"
phx-click="change_status"
phx-value-path={lang_path}
phx-value-status="draft"
onclick="document.activeElement.blur()"
class={[
"btn-xs",
if(lang_metadata.status == "draft",
do: "active",
else: ""
)
]}
>
<span class="badge badge-warning badge-sm">
{gettext("Draft")}
</span>
</button>
</li>
<li>
<button
type="button"
phx-click="change_status"
phx-value-path={lang_path}
phx-value-status="published"
onclick="document.activeElement.blur()"
class={[
"btn-xs",
if(lang_metadata.status == "published",
do: "active",
else: ""
)
]}
>
<span class="badge badge-success badge-sm">
{gettext("Published")}
</span>
</button>
</li>
<li>
<button
type="button"
phx-click="change_status"
phx-value-path={lang_path}
phx-value-status="archived"
onclick="document.activeElement.blur()"
class={[
"btn-xs",
if(lang_metadata.status == "archived",
do: "active",
else: ""
)
]}
>
<span class="badge badge-neutral badge-sm">
{gettext("Archived")}
</span>
</button>
</li>
</ul>
</div>
<% else %>
<%!-- Empty placeholder to maintain spacing --%>
<div class="dropdown dropdown-end">
<div class="btn btn-xs btn-outline shadow-none w-full invisible pointer-events-none">
</div>
</div>
<% end %>
<%!-- Layer 2: Edit button --%>
<.link
navigate={
PhoenixKit.Utils.Routes.path(
"/admin/blogging/#{post.blog}/edit?path=#{URI.encode(lang_path)}",
locale: @current_locale
)
}
class={[
"btn btn-xs shadow-none w-full",
if(is_enabled,
do: "btn-outline",
else: "bg-base-200 text-base-content/60 border-base-300"
)
]}
title={
if lang_info,
do: "Edit #{lang_info.name}",
else: "Edit #{lang_code}"
}
>
<.icon name="hero-pencil-square" class="w-3 h-3" />
<%= if hide_flags do %>
{gettext("Edit")}
<% else %>
<span class="text-base">{flag}</span> {lang_code
|> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
|> String.upcase()}
<% end %>
</.link>
<%!-- Layer 3: View button (only for published posts in enabled languages) --%>
<%= if lang_metadata && lang_metadata.status == "published" && is_enabled do %>
<a
href={
BlogHTML.build_post_url(
@blog_slug,
post,
lang_code
)
}
target="_blank"
class="btn btn-xs btn-outline shadow-none w-full"
title={gettext("View on public site")}
>
<.icon name="hero-eye" class="w-3 h-3" />
{gettext("View")}
</a>
<% else %>
<%!-- Empty placeholder to maintain spacing --%>
<div class="btn btn-xs btn-outline shadow-none w-full invisible pointer-events-none">
</div>
<% end %>
</div>
<% else %>
<%!-- Add button for missing files (only if language is enabled) --%>
<%= if is_enabled do %>
<div class="flex flex-col gap-1 min-w-[95px]">
<%!-- Empty placeholder to maintain spacing --%>
<div class="dropdown dropdown-end">
<div class="btn btn-xs btn-outline shadow-none w-full invisible pointer-events-none">
</div>
</div>
<%!-- Add button --%>
<button
type="button"
phx-click="add_language"
phx-value-path={post.path}
phx-value-language={lang_code}
class="btn btn-success btn-outline btn-xs shadow-none w-full"
title={
if lang_info,
do: "Add #{lang_info.name}",
else: "Add #{lang_code}"
}
>
<.icon name="hero-plus" class="w-3 h-3 mr-1" />
<span class="text-lg">{flag}</span> {lang_code
|> PhoenixKit.Modules.Languages.DialectMapper.extract_base()
|> String.upcase()}
</button>
</div>
<% end %>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Scroll fade detection and sync script --%>
<script>
(function() {
var userScroller = null;
var isSyncing = false;
function updateScrollFades() {
document.querySelectorAll('.scroll-fade-wrapper').forEach(function(wrapper) {
var scroller = wrapper.querySelector('.scroll-fade-scroller');
var indicatorLeft = wrapper.querySelector('.scroll-fade-indicator-left');
var indicatorRight = wrapper.querySelector('.scroll-fade-indicator-right');
if (scroller && indicatorLeft && indicatorRight) {
var isScrollable = scroller.scrollWidth > scroller.clientWidth;
var isNotAtStart = scroller.scrollLeft > 5;
var isNotAtEnd = scroller.scrollLeft < (scroller.scrollWidth - scroller.clientWidth - 5);
indicatorLeft.style.opacity = (isScrollable && isNotAtStart) ? '1' : '0';
indicatorRight.style.opacity = (isScrollable && isNotAtEnd) ? '1' : '0';
}
});
}
function syncScrollers(sourceScroller) {
if (isSyncing) return;
isSyncing = true;
var scrollLeft = sourceScroller.scrollLeft;
document.querySelectorAll('.scroll-fade-scroller').forEach(function(scroller) {
if (scroller !== sourceScroller) {
scroller.scrollLeft = scrollLeft;
}
});
updateScrollFades();
requestAnimationFrame(function() {
isSyncing = false;
});
}
function attachScrollSync() {
document.querySelectorAll('.scroll-fade-scroller').forEach(function(scroller) {
if (!scroller.dataset.scrollSyncAttached) {
// Track user interaction - works for scrollbar drag, touch, and shift+wheel
scroller.addEventListener('pointerdown', function() {
userScroller = scroller;
}, { passive: true });
scroller.addEventListener('wheel', function() {
userScroller = scroller;
}, { passive: true });
scroller.addEventListener('scroll', function() {
if (userScroller === scroller) {
syncScrollers(scroller);
} else {
updateScrollFades();
}
}, { passive: true });
scroller.dataset.scrollSyncAttached = 'true';
}
});
}
// Clear user scroller after interaction ends
document.addEventListener('pointerup', function() {
setTimeout(function() { userScroller = null; }, 100);
});
// Run on load and after LiveView updates
updateScrollFades();
attachScrollSync();
window.addEventListener('resize', updateScrollFades);
// Re-run when LiveView updates the DOM
if (window.liveSocket) {
window.liveSocket.hooks.ScrollFadeUpdate = {
mounted() { updateScrollFades(); attachScrollSync(); },
updated() { updateScrollFades(); attachScrollSync(); }
};
}
// Also handle phx:update events
window.addEventListener('phx:update', function() {
updateScrollFades();
attachScrollSync();
});
})();
</script>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>