Packages

phoenix_kit

1.7.74
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 modules publishing web listing.html.heex
Raw

lib/modules/publishing/web/listing.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}
>
<div class="container flex-col mx-auto px-4 py-6">
<%!-- Header Section --%>
<% group_slug = (@current_group && @current_group["slug"]) || @group_slug || "your-group" %>
<% url_prefix =
PhoenixKit.Config.get_url_prefix()
|> case do
"/" -> ""
prefix -> prefix
end %>
<%!-- Use primary language for public URL preview --%>
<% public_url =
if length(@enabled_languages) == 1 do
@endpoint_url <> url_prefix <> "/" <> group_slug
else
@endpoint_url <> url_prefix <> "/#{@primary_language}/" <> group_slug
end %>
<.admin_page_header back={Routes.path("/admin/publishing")}>
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold text-base-content">
{(@current_group && @current_group["name"]) || @group_slug || gettext("Group")}
</h1>
<% has_published = Map.get(@post_status_counts, "published", 0) > 0 %>
<%= if has_published do %>
<div class="text-sm text-base-content/60 mt-0.5">
<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>
<% end %>
</.admin_page_header>
<%!-- Primary Language Update Banner --%>
<%= if @primary_language_status && (@primary_language_status.needs_backfill > 0 or @primary_language_status.needs_migration > 0) do %>
<% total_needing =
@primary_language_status.needs_backfill + @primary_language_status.needs_migration %>
<div class="flex items-center gap-3 px-4 py-3 rounded-lg bg-gradient-to-r from-warning/10 to-amber-500/10 border border-warning/30 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-br from-warning to-amber-500 text-white shadow-sm">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
</div>
<div class="flex-1">
<span class="text-sm font-medium text-warning">
{ngettext(
"1 post needs primary language update",
"%{count} posts need primary language update",
total_needing,
count: total_needing
)}
</span>
<p class="text-xs text-base-content/60">
{gettext(
"Save the current language setting (%{lang}) to these posts to ensure they remain accessible if the global setting changes.",
lang: @primary_language_name
)}
</p>
</div>
<button
type="button"
class="btn btn-warning btn-sm"
phx-click="update_primary_language"
data-confirm={
gettext("Update %{count} posts to use the current primary language?",
count: total_needing
)
}
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" />
{gettext("Update All")}
</button>
</div>
<% end %>
<div class="space-y-4">
<div class="flex-1">
<div class="flex flex-wrap gap-2 items-center justify-end mb-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>
<%!-- Status Tabs — only show tabs that have posts, hide if only 1 tab --%>
<% all_tabs = [
{"published", gettext("Published"), nil},
{"draft", gettext("Draft"), nil},
{"archived", gettext("Archived"), nil},
{"trashed", gettext("Trash"), "error"}
] %>
<% visible_tabs =
Enum.filter(all_tabs, fn {mode, _label, _color} ->
Map.get(@post_status_counts, mode, 0) > 0 or @post_view_mode == mode
end) %>
<%= if length(visible_tabs) > 1 do %>
<div class="flex items-center gap-0.5 border-b border-base-200 mb-3 overflow-x-auto">
<%= for {mode, label, color} <- visible_tabs do %>
<button
type="button"
phx-click="switch_post_view"
phx-value-mode={mode}
class={"px-3 py-1 text-xs font-medium border-b-2 transition-colors whitespace-nowrap cursor-pointer #{cond do
@post_view_mode == mode and color == "error" -> "border-error text-error"
@post_view_mode == mode -> "border-primary text-primary"
true -> "border-transparent text-base-content/50 hover:text-base-content"
end}"}
>
{label}
</button>
<% end %>
</div>
<% end %>
<%= if @loading do %>
<%!-- Skeleton placeholders matching post card layout.
Uses bg-base-200 instead of DaisyUI skeleton class because
skeleton depends on --color-base-300 which is white in some themes. --%>
<div class="grid gap-4 animate-pulse">
<%= for _i <- 1..3 do %>
<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 space-y-3">
<div class="flex items-center gap-2">
<div class="bg-base-200 h-3 w-32 rounded"></div>
<div class="bg-base-200 h-4 w-16 rounded-full"></div>
</div>
<div class="bg-base-200 h-6 w-3/4 rounded"></div>
<div class="border-t border-base-200 pt-3 mt-1">
<div class="flex items-center gap-2">
<div class="bg-base-200 h-8 w-28 rounded-lg"></div>
<div class="bg-base-200 h-8 w-24 rounded-lg"></div>
</div>
</div>
<div class="border-t border-base-200 pt-2 flex justify-end">
<div class="bg-base-200 h-6 w-6 rounded"></div>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<%= if @posts == [] do %>
<div class="text-center py-8 text-base-content/60">
<%= if @post_view_mode == "trashed" do %>
<.icon name="hero-trash" class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="text-sm">{gettext("Trash is empty")}</p>
<% else %>
<.icon name="hero-document-text" class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="text-sm">{gettext("No posts found")}</p>
<% end %>
</div>
<% else %>
<% date_counts = PublishingHTML.build_date_counts(@posts) %>
<% visible_posts = Enum.take(@posts, @visible_count) %>
<div class="grid gap-4">
<%= for post <- visible_posts do %>
<% group_slug =
post.group || (@current_group && @current_group["slug"]) || @group_slug ||
"group" %>
<%!-- Use post's stored primary language, falling back to global only if not stored --%>
<% post_primary_lang = post[:primary_language] || @primary_language %>
<%!-- Use post's primary language for the public URL --%>
<% public_url =
PublishingHTML.build_post_url(
group_slug,
post,
post_primary_lang,
date_counts
) %>
<% full_public_url = @endpoint_url <> public_url %>
<%!-- Version info (only for slug-mode posts) --%>
<% version_count = length(Map.get(post, :available_versions) || []) %>
<% is_versioned = PhoenixKit.Modules.Publishing.Constants.slug_mode?(post.mode) %>
<% post_status = post.metadata.status %>
<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>
<%!-- Version info (show published version and count) --%>
<%= if is_versioned do %>
<% {display_version, display_status, _label} =
get_display_version(post) %>
<span class={[
"badge badge-xs gap-1",
display_status == "published" && "badge-success",
display_status != "published" && "badge-ghost"
]}>
<%= if version_count > 1 do %>
{ngettext("%{count} version", "%{count} versions", version_count,
count: version_count
)}
<span class="opacity-50">|</span>
<% end %>
{gettext("showing")} v{display_version}
</span>
<% end %>
</div>
<h3 class="text-xl font-semibold text-base-content mt-1 truncate">
<%= if @post_view_mode == "trashed" do %>
{post.metadata.title || gettext("Untitled post")}
<% else %>
<.link
href={
PhoenixKit.Utils.Routes.path(
"/admin/publishing/#{group_slug}/#{post.uuid}/edit?lang=#{post_primary_lang}"
)
}
class="hover:text-primary transition-colors"
>
{post.metadata.title || gettext("Untitled post")}
</.link>
<% end %>
</h3>
<%= if post_status == "published" and @post_view_mode != "trashed" do %>
<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>
<% end %>
</div>
<%!-- Languages section --%>
<div class="border-t border-base-200 pt-3 mt-3 space-y-2">
<%!-- Build all languages and separate primary from others --%>
<% all_languages =
build_post_languages(
post,
@group_slug,
@enabled_languages,
@current_locale
) %>
<% primary_lang = Enum.find(all_languages, & &1.is_primary) %>
<% other_languages = Enum.reject(all_languages, & &1.is_primary) %>
<%!-- Primary language + status control --%>
<%= if primary_lang do %>
<div class="flex items-center gap-2">
<%= if @post_view_mode == "trashed" do %>
<span
class="inline-flex items-center gap-2 px-2.5 h-[2.5em] rounded-lg bg-base-200/60 border border-base-300 opacity-60"
title={primary_lang.name}
>
<span class="text-xs text-base-content/50 font-medium">
{gettext("Primary")}
</span>
<span class={[
"rounded-full inline-block w-2.5 h-2.5",
cond do
not primary_lang.exists -> "bg-base-content/20"
post_status == "published" -> "bg-success"
post_status == "draft" -> "bg-warning"
post_status == "archived" -> "bg-base-content/40"
true -> "bg-base-content/20"
end
]}>
</span>
<span class="text-sm font-bold uppercase text-base-content/40">
{String.upcase(primary_lang.display_code)}
</span>
</span>
<% else %>
<button
type="button"
phx-click="language_action"
phx-value-language={primary_lang.code}
phx-value-uuid={post[:uuid]}
phx-value-status={primary_lang.status}
class="inline-flex items-center gap-2 px-2.5 h-[2.5em] rounded-lg bg-primary/10 hover:bg-primary/20 border border-primary/20 cursor-pointer transition-colors"
title={primary_lang.name}
>
<span class="text-xs text-primary/70 font-medium">
{gettext("Primary")}
</span>
<span class={[
"rounded-full inline-block w-2.5 h-2.5",
cond do
not primary_lang.exists -> "bg-base-content/20"
post_status == "published" -> "bg-success"
post_status == "draft" -> "bg-warning"
post_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"
post_status == "published" -> "text-success"
post_status == "draft" -> "text-warning"
post_status == "archived" -> "text-base-content/40"
true -> "text-base-content/20"
end
]}>
{String.upcase(primary_lang.display_code)}
</span>
</button>
<% end %>
<%= if @post_view_mode != "trashed" do %>
<form
id={"status-#{post[:uuid]}-#{post_status}"}
phx-change="change_status"
>
<input type="hidden" name="uuid" value={post[:uuid]} />
<div class={[
"inline-flex items-center h-[2.5em] rounded-lg border transition-colors",
post_status == "published" &&
"bg-success/10 border-success/20 hover:bg-success/20",
post_status == "draft" &&
"bg-warning/10 border-warning/20 hover:bg-warning/20",
post_status == "archived" &&
"bg-base-200/60 border-base-300 hover:bg-base-200"
]}>
<select
name="status"
class={[
"select text-xs font-medium min-h-0 h-full pl-2.5 pr-7 bg-transparent border-none focus:outline-none cursor-pointer w-auto",
post_status == "published" && "text-success",
post_status == "draft" && "text-warning",
post_status == "archived" && "text-base-content/60"
]}
>
<option value="draft" selected={post_status == "draft"}>
{gettext("Draft")}
</option>
<option value="published" selected={post_status == "published"}>
{gettext("Published")}
</option>
<option value="archived" selected={post_status == "archived"}>
{gettext("Archived")}
</option>
</select>
</div>
</form>
<% end %>
<%= if @post_view_mode != "trashed" do %>
<button
type="button"
phx-click="trash_post"
phx-value-uuid={post[:uuid]}
class="inline-flex items-center gap-1.5 px-2.5 h-[2.5em] rounded-lg border border-error/20 bg-error/5 hover:bg-error/15 text-error/60 hover:text-error transition-colors cursor-pointer"
data-confirm={gettext("Move this post to trash?")}
title={gettext("Move to trash")}
>
<.icon name="hero-trash" class="w-3.5 h-3.5" />
</button>
<% else %>
<button
type="button"
phx-click="restore_post"
phx-value-uuid={post[:uuid]}
class="inline-flex items-center gap-1.5 px-2.5 h-[2.5em] rounded-lg border border-success/30 bg-success/10 hover:bg-success/20 text-success font-medium transition-colors cursor-pointer"
title={gettext("Restore")}
>
<.icon name="hero-arrow-uturn-left" class="w-3.5 h-3.5" />
<span class="text-xs font-medium">{gettext("Restore")}</span>
</button>
<% end %>
</div>
<% end %>
<%!-- Other translations (excluding primary) --%>
<%= if @post_view_mode == "trashed" do %>
<div class="opacity-60 pointer-events-none">
<.publishing_language_switcher
languages={other_languages}
show_status={true}
show_add={false}
size={:sm}
/>
</div>
<% else %>
<.publishing_language_switcher
languages={other_languages}
show_status={true}
show_add={true}
on_click="language_action"
size={:sm}
/>
<% end %>
</div>
<%!-- Translation Progress Bar --%>
<%= if translation_status = Map.get(@translating_posts, post.slug) do %>
<div class="border-t border-base-200 pt-3 mt-3">
<%= if translation_status.status == :in_progress do %>
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-base-content/70 flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>
{gettext("Translating...")}
</span>
<span class="font-medium">
{translation_status.completed} / {translation_status.total}
</span>
</div>
<progress
class="progress progress-primary w-full h-2"
value={translation_status.completed}
max={translation_status.total}
>
</progress>
<% else %>
<div class="flex items-center gap-2 text-xs text-success">
<.icon name="hero-check-circle" class="w-4 h-4" />
{ngettext(
"Translation complete - %{count} language",
"Translation complete - %{count} languages",
translation_status.success_count,
count: translation_status.success_count
)}
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<%= if length(@posts) > @visible_count do %>
<div class="flex justify-center mt-4">
<button
type="button"
phx-click="load_more"
class="btn btn-outline btn-sm"
>
{gettext("Load more")}
</button>
</div>
<% end %>
<% end %>
<% end %>
</div>
</div>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>