defmodule PhoenixKit.Modules.Publishing.Web.Components.LanguageSwitcher do
@moduledoc """
Unified language switcher component for publishing posts.
Displays available languages as a compact inline list with status indicators.
Flexible enough for admin interfaces (with status dots) and public pages (links only).
## Display Format
Admin mode: ● EN | ● FR | ○ ES
- Green dot (●): Published
- Yellow dot (●): Draft
- Gray dot (●): Archived
- Empty dot (○): No translation exists
Public mode: EN | FR | ES
- No status indicators
- Only shows languages with published translations
## Examples
# Admin: publishing listing with edit links and status indicators
<.publishing_language_switcher
languages={@languages}
current_language="en"
on_click="switch_language"
show_status={true}
/>
# Admin: editor with switch functionality
<.publishing_language_switcher
languages={@languages}
current_language={@current_language}
on_click="switch_language"
show_status={true}
show_add={true}
/>
# Public: post page with translation links
<.publishing_language_switcher
languages={@translations}
current_language={@current_language}
show_status={false}
/>
"""
use Phoenix.Component
use Gettext, backend: PhoenixKitWeb.Gettext
@doc """
Renders a compact inline language switcher.
## Attributes
- `languages` - List of language maps with keys: `code`, `name`, `status`, `url`, `exists`
- `current_language` - Currently active language code
- `show_status` - Show status indicator dots (default: true)
- `show_add` - Show "add" option for missing languages (default: false)
- `show_flags` - Show flag emojis (default: false)
- `on_click` - Event name for click handler (optional, uses href if not provided)
- `class` - Additional CSS classes
- `size` - Size variant: :xs, :sm, :md (default: :sm)
## Language Map Structure
Each language in the list should have:
- `code` - Language code (e.g., "en", "fr")
- `name` - Display name (e.g., "English", "French") - optional
- `flag` - Flag emoji (e.g., "🇺🇸") - optional
- `status` - Post status: "published", "draft", "archived", or nil if not exists
- `url` - URL to navigate to (for public mode or href navigation)
- `exists` - Boolean, whether translation file exists (default: inferred from status)
- `enabled` - Boolean, whether the language is enabled in the Languages module (default: true)
- `known` - Boolean, whether the language code is recognized (default: true)
"""
attr :languages, :list, required: true
attr :current_language, :string, default: nil
attr :show_status, :boolean, default: true
attr :show_add, :boolean, default: false
attr :show_flags, :boolean, default: false
attr :on_click, :string, default: nil
attr :phx_target, :any, default: nil
attr :class, :string, default: ""
attr :size, :atom, default: :sm, values: [:xs, :sm, :md]
def publishing_language_switcher(assigns) do
# Filter languages for public mode (only show existing/published)
languages =
if assigns.show_status do
assigns.languages
else
Enum.filter(assigns.languages, fn lang ->
lang_exists?(lang) && lang[:status] == "published"
end)
end
assigns = assign(assigns, :filtered_languages, languages)
~H"""
<%= for {lang, index} <- Enum.with_index(@filtered_languages) do %>
<%= if index > 0 do %>
|
<% end %>
<.language_item
lang={lang}
current_language={@current_language}
show_status={@show_status}
show_add={@show_add}
show_flags={@show_flags}
on_click={@on_click}
phx_target={@phx_target}
size={@size}
/>
<% end %>
"""
end
attr :lang, :map, required: true
attr :current_language, :string, default: nil
attr :show_status, :boolean, default: true
attr :show_add, :boolean, default: false
attr :show_flags, :boolean, default: false
attr :on_click, :string, default: nil
attr :phx_target, :any, default: nil
attr :size, :atom, default: :sm
defp language_item(assigns) do
lang = assigns.lang
is_current = assigns.current_language == lang[:code]
exists = lang_exists?(lang)
status = lang[:status]
enabled = lang_enabled?(lang)
known = lang_known?(lang)
is_primary = lang_is_primary?(lang)
assigns =
assigns
|> assign(:is_current, is_current)
|> assign(:exists, exists)
|> assign(:status, status)
|> assign(:enabled, enabled)
|> assign(:known, known)
|> assign(:is_primary, is_primary)
~H"""
<%= if @on_click do %>
<% else %>
<%= if @lang[:url] do %>
<.language_content
lang={@lang}
exists={@exists}
status={@status}
show_status={@show_status}
show_add={@show_add}
show_flags={@show_flags}
is_current={@is_current}
size={@size}
enabled={@enabled}
known={@known}
is_primary={@is_primary}
/>
<% else %>
<.language_content
lang={@lang}
exists={@exists}
status={@status}
show_status={@show_status}
show_add={@show_add}
show_flags={@show_flags}
is_current={@is_current}
size={@size}
enabled={@enabled}
known={@known}
is_primary={@is_primary}
/>
<% end %>
<% end %>
"""
end
attr :lang, :map, required: true
attr :exists, :boolean, required: true
attr :status, :string, default: nil
attr :show_status, :boolean, default: true
attr :show_add, :boolean, default: false
attr :show_flags, :boolean, default: false
attr :is_current, :boolean, default: false
attr :size, :atom, default: :sm
attr :enabled, :boolean, default: true
attr :known, :boolean, default: true
attr :is_primary, :boolean, default: false
defp language_content(assigns) do
~H"""
<%= if @show_status do %>
<% end %>
<%= if @show_flags && @lang[:flag] do %>
{@lang[:flag]}
<% end %>
{get_display_code(@lang)}
"""
end
# Check if language translation exists
defp lang_exists?(%{exists: exists}) when is_boolean(exists), do: exists
defp lang_exists?(%{status: status}) when is_binary(status), do: true
defp lang_exists?(%{"exists" => exists}) when is_boolean(exists), do: exists
defp lang_exists?(%{"status" => status}) when is_binary(status), do: true
defp lang_exists?(_), do: false
# Check if language is enabled in the Languages module (default: true for backwards compat)
defp lang_enabled?(%{enabled: enabled}) when is_boolean(enabled), do: enabled
defp lang_enabled?(%{"enabled" => enabled}) when is_boolean(enabled), do: enabled
defp lang_enabled?(_), do: true
# Check if language code is a recognized/known language (default: true for backwards compat)
defp lang_known?(%{known: known}) when is_boolean(known), do: known
defp lang_known?(%{"known" => known}) when is_boolean(known), do: known
defp lang_known?(_), do: true
# Check if language is the primary language (default: false for backwards compat)
defp lang_is_primary?(%{is_primary: is_primary}) when is_boolean(is_primary), do: is_primary
defp lang_is_primary?(%{"is_primary" => is_primary}) when is_boolean(is_primary), do: is_primary
defp lang_is_primary?(_), do: false
# Get display code for a language
# Uses display_code if provided (for dialect-aware display), otherwise formats code
defp get_display_code(%{display_code: display_code}) when is_binary(display_code) do
String.upcase(display_code)
end
defp get_display_code(%{"display_code" => display_code}) when is_binary(display_code) do
String.upcase(display_code)
end
defp get_display_code(%{code: code}) when is_binary(code) do
format_code(code)
end
defp get_display_code(%{"code" => code}) when is_binary(code) do
format_code(code)
end
defp get_display_code(_), do: ""
# Format language code for display (base code only)
defp format_code(nil), do: ""
defp format_code(code) when is_binary(code) do
code
|> String.split("-")
|> List.first()
|> String.upcase()
end
# Status dot styling
# Dot color follows status regardless of enabled/known state
# (strikethrough on text indicates disabled/unknown, not the dot)
defp status_dot_classes(exists, status, size, _enabled, _known, _is_primary) do
base = ["rounded-full", "inline-block", dot_size_class(size)]
color =
cond do
!exists -> "bg-base-content/20"
status == "published" -> "bg-success"
status == "draft" -> "bg-warning"
status == "archived" -> "bg-base-content/40"
true -> "bg-base-content/20"
end
base ++ [color]
end
# Item container classes
defp item_classes(is_current, exists, show_add, size, enabled, known) do
base = [
"inline-flex items-center rounded transition-colors",
size_padding_class(size)
]
state =
cond do
# Disabled or unknown languages: grey styling but still clickable
is_current and (!enabled or !known) ->
"bg-base-content/10 text-base-content/50 font-semibold cursor-pointer"
(!enabled or !known) and exists ->
"text-base-content/40 hover:bg-base-200/50 cursor-pointer"
is_current ->
"bg-primary/10 text-primary font-semibold"
!exists && show_add ->
"text-success hover:bg-success/10 cursor-pointer"
exists ->
"hover:bg-base-200 cursor-pointer"
true ->
"text-base-content/40"
end
base ++ [state]
end
# Code text classes
# Text color matches the status dot color
defp code_classes(exists, status, is_current, size, enabled, known, is_primary) do
base = [size_text_class(size)]
# Primary language gets bold, current gets semibold, others get medium
weight =
cond do
is_primary -> "font-bold"
is_current -> "font-semibold"
true -> "font-medium"
end
# Add line-through for disabled or unknown languages
decoration = if !enabled or !known, do: "line-through", else: nil
# Text color matches the dot color
color =
cond do
!exists -> "text-base-content/20"
status == "published" -> "text-success"
status == "draft" -> "text-warning"
status == "archived" -> "text-base-content/40"
true -> "text-base-content/20"
end
Enum.filter(base ++ [weight, decoration, color], & &1)
end
# Size-based classes
defp dot_size_class(:xs), do: "w-1.5 h-1.5"
defp dot_size_class(:sm), do: "w-2 h-2"
defp dot_size_class(:md), do: "w-2.5 h-2.5"
defp size_text_class(:xs), do: "text-xs"
defp size_text_class(:sm), do: "text-sm"
defp size_text_class(:md), do: "text-base"
defp size_padding_class(:xs), do: "px-1 py-0.5"
defp size_padding_class(:sm), do: "px-1.5 py-0.5"
defp size_padding_class(:md), do: "px-2 py-1"
defp size_gap_class(:xs), do: "gap-0.5"
defp size_gap_class(:sm), do: "gap-1"
defp size_gap_class(:md), do: "gap-1.5"
defp size_separator_class(:xs), do: "text-xs"
defp size_separator_class(:sm), do: "text-sm"
defp size_separator_class(:md), do: "text-base"
defp flag_size_class(:xs), do: "text-sm"
defp flag_size_class(:sm), do: "text-base"
defp flag_size_class(:md), do: "text-lg"
# Generate title/tooltip text
# Only show status in tooltip when show_status is true (admin mode)
defp language_title(lang, exists, status, show_status, enabled, known) do
name = lang[:name] || format_code(lang[:code])
if show_status do
build_admin_title(name, exists, status, enabled, known)
else
name
end
end
# Build title for admin mode with status indicators
defp build_admin_title(name, exists, status, enabled, known) do
cond do
!known and exists -> gettext("%{language} (Unknown language file)", language: name)
!enabled and exists -> build_disabled_title(name, status)
!exists -> gettext("Add %{language} translation", language: name)
true -> build_status_title(name, status)
end
end
defp build_disabled_title(name, status) do
status_text = status_label(status)
gettext("%{language} (Disabled - %{status})", language: name, status: status_text)
end
defp build_status_title(name, "published"),
do: gettext("%{language} (Published)", language: name)
defp build_status_title(name, "draft"), do: gettext("%{language} (Draft)", language: name)
defp build_status_title(name, "archived"), do: gettext("%{language} (Archived)", language: name)
defp build_status_title(name, _), do: name
defp status_label("published"), do: gettext("Published")
defp status_label("draft"), do: gettext("Draft")
defp status_label("archived"), do: gettext("Archived")
defp status_label(_), do: gettext("Unknown")
end