diff --git a/lib/phoenix_kit/migrations/postgres.ex b/lib/phoenix_kit/migrations/postgres.ex index 811ce0cfa..b1b8daa71 100644 --- a/lib/phoenix_kit/migrations/postgres.ex +++ b/lib/phoenix_kit/migrations/postgres.ex @@ -248,7 +248,7 @@ defmodule PhoenixKit.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 27 + @current_version 28 @default_prefix "public" @doc false diff --git a/lib/phoenix_kit/migrations/postgres/v28.ex b/lib/phoenix_kit/migrations/postgres/v28.ex new file mode 100644 index 000000000..fb7cbbe00 --- /dev/null +++ b/lib/phoenix_kit/migrations/postgres/v28.ex @@ -0,0 +1,83 @@ +defmodule PhoenixKit.Migrations.Postgres.V28 do + @moduledoc """ + Migration V28: Add preferred_locale field to users table for dialect preferences. + + This migration adds support for user-specific language dialect preferences, + allowing authenticated users to choose their preferred variant (e.g., en-GB vs en-US) + while URLs continue to show simplified base language codes (/en/ instead of /en-US/). + + ## Changes + - Adds `preferred_locale` column to `phoenix_kit_users` table (nullable string, size 10) + - Creates index on `preferred_locale` for potential locale-based queries + - Supports full dialect codes (en-US, es-MX, zh-Hans-CN, etc.) + + ## Requirements + - PostgreSQL database + - PhoenixKit V27 or higher + + ## Purpose + Enable simplified URL structure with dialect preferences: + - URLs show base codes: `/en/`, `/es/`, `/fr/` + - Users can save preferred dialects: "en-GB", "es-MX", "pt-BR" + - Guest users get default dialect mapping (en → en-US) + - Translation system uses full dialect codes internally + + ## Usage + Once migrated, users can update their locale preference: + + PhoenixKit.Users.Auth.update_user_locale(user, "en-GB") + + When visiting `/en/dashboard`, the system will: + 1. Detect base code "en" from URL + 2. Resolve to user's preferred_locale ("en-GB") + 3. Use "en-GB" for translations while URL stays `/en/` + + ## Validation + - Format validation: matches ~r/^[a-z]{2}(-[A-Z]{2})?$/ + - Existence validation: must be in predefined language list + - NULL allowed: defaults to system dialect mapping + + ## Size Rationale + - Size 10 supports extended codes like "zh-Hans-CN" (10 chars) + - Covers all standard language-REGION codes (5 chars: "en-US") + - Allows future BCP 47 extensions if needed + + ## Notes + - Idempotent: Safe to run multiple times + - No default value: NULL indicates "use system default" + - Index improves potential analytics queries + - Backward compatible: existing users have NULL (use defaults) + """ + + use Ecto.Migration + + def up(%{prefix: prefix} = _opts) do + alter table(:phoenix_kit_users, prefix: prefix) do + add :preferred_locale, :string, size: 10 + end + + # Create index for potential locale-based queries + create index(:phoenix_kit_users, [:preferred_locale], prefix: prefix) + + # Update version comment on phoenix_kit table for version tracking + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '28'" + end + + def down(%{prefix: prefix} = _opts) do + # Remove index + drop index(:phoenix_kit_users, [:preferred_locale], prefix: prefix) + + # Remove column + alter table(:phoenix_kit_users, prefix: prefix) do + remove :preferred_locale + end + + # Update version comment on phoenix_kit table to previous version + execute "COMMENT ON TABLE #{prefix_table_name("phoenix_kit", prefix)} IS '27'" + end + + # Helper functions + + defp prefix_table_name(table_name, nil), do: table_name + defp prefix_table_name(table_name, prefix), do: "#{prefix}.#{table_name}" +end diff --git a/lib/phoenix_kit/modules/languages/dialect_mapper.ex b/lib/phoenix_kit/modules/languages/dialect_mapper.ex new file mode 100644 index 000000000..3e4d3ea7b --- /dev/null +++ b/lib/phoenix_kit/modules/languages/dialect_mapper.ex @@ -0,0 +1,379 @@ +defmodule PhoenixKit.Modules.Languages.DialectMapper do + @moduledoc """ + Handles mapping between base language codes (en, es) and full dialect codes (en-US, es-MX). + + This module provides the core logic for PhoenixKit's simplified URL architecture where + URLs show base codes (/en/) but translations use full dialect codes (en-US). + + ## Architecture + + PhoenixKit uses a two-tier locale system: + + 1. **Base Language Codes** - Used in URLs for simplicity + - Format: 2-letter ISO 639-1 codes (en, es, fr, de, pt, zh, ja, etc.) + - Examples: `/en/dashboard`, `/es/admin`, `/fr/users` + - User-facing, SEO-friendly, easy to remember + + 2. **Full Dialect Codes** - Used internally for translations + - Format: BCP 47 language tags (en-US, es-MX, pt-BR, zh-CN) + - Examples: en-US, en-GB, es-ES, es-MX, pt-PT, pt-BR + - Translation-aware, respects regional differences + + ## Data Flow + + ``` + User visits: /en/dashboard + ↓ + Extract base: "en" + ↓ + Resolve dialect: "en-US" (default) or user.preferred_locale ("en-GB") + ↓ + Set Gettext: "en-US" or "en-GB" + ↓ + Generate URLs: Always use base code "en" + ``` + + ## Default Dialect Mapping + + When no user preference exists, base codes map to most common regional variants: + - `en` → `en-US` (American English) + - `es` → `es-ES` (European Spanish) + - `pt` → `pt-BR` (Brazilian Portuguese) + - `zh` → `zh-CN` (Simplified Chinese) + - `de` → `de-DE` (German Germany) + - `fr` → `fr-FR` (French France) + + ## User Preferences + + Authenticated users can override default mappings: + - User prefers British English: sets `preferred_locale` = "en-GB" + - Visits `/en/dashboard` + - System uses "en-GB" for translations + - URLs remain `/en/` (not `/en-GB/`) + + ## Examples + + # Extract base language from full dialect + iex> DialectMapper.extract_base("en-US") + "en" + + iex> DialectMapper.extract_base("es-MX") + "es" + + # Convert base to default dialect + iex> DialectMapper.base_to_dialect("en") + "en-US" + + iex> DialectMapper.base_to_dialect("pt") + "pt-BR" + + # Resolve dialect with user preference + iex> user = %User{preferred_locale: "en-GB"} + iex> DialectMapper.resolve_dialect("en", user) + "en-GB" + + iex> DialectMapper.resolve_dialect("en", nil) + "en-US" + + ## Validation + + iex> DialectMapper.valid_base_code?("en") + true + + iex> DialectMapper.valid_base_code?("xx") + false + + ## Getting Available Dialects + + iex> DialectMapper.dialects_for_base("en") + ["en-US", "en-GB", "en-CA", "en-AU"] + + iex> DialectMapper.dialects_for_base("es") + ["es-ES", "es-MX", "es-AR", "es-CO"] + """ + + alias PhoenixKit.Modules.Languages + + # Default dialect mapping for most common variants + # Based on usage statistics and regional population + @default_dialects %{ + "en" => "en-US", + # English + "es" => "es-ES", + # Spanish + "fr" => "fr-FR", + # French + "de" => "de-DE", + # German + "pt" => "pt-BR", + # Portuguese (Brazilian Portuguese more common) + "zh" => "zh-CN", + # Chinese (Simplified more common) + # Languages without regional variants map to themselves + "ar" => "ar", + # Arabic + "ja" => "ja", + # Japanese + "ko" => "ko", + # Korean + "it" => "it", + # Italian + "ru" => "ru", + # Russian + "hi" => "hi", + # Hindi + "bn" => "bn", + # Bengali + "pa" => "pa", + # Punjabi + "jv" => "jv", + # Javanese + "vi" => "vi", + # Vietnamese + "tr" => "tr", + # Turkish + "pl" => "pl", + # Polish + "uk" => "uk", + # Ukrainian + "th" => "th", + # Thai + "nl" => "nl", + # Dutch + "sv" => "sv", + # Swedish + "no" => "no", + # Norwegian + "da" => "da", + # Danish + "fi" => "fi", + # Finnish + "cs" => "cs", + # Czech + "hu" => "hu", + # Hungarian + "ro" => "ro", + # Romanian + "el" => "el", + # Greek + "he" => "he", + # Hebrew + "id" => "id", + # Indonesian + "ms" => "ms", + # Malay + "fa" => "fa", + # Persian + "sw" => "sw", + # Swahili + "ta" => "ta", + # Tamil + "te" => "te", + # Telugu + "mr" => "mr", + # Marathi + "ur" => "ur", + # Urdu + "gu" => "gu", + # Gujarati + "kn" => "kn", + # Kannada + "ml" => "ml" + # Malayalam + } + + @doc """ + Extracts base language code from full dialect code. + + Splits on hyphen and returns first part (lowercased). + Handles both dialect codes (en-US) and base codes (en). + + ## Examples + + iex> DialectMapper.extract_base("en-US") + "en" + + iex> DialectMapper.extract_base("es-MX") + "es" + + iex> DialectMapper.extract_base("zh-Hans-CN") + "zh" + + iex> DialectMapper.extract_base("ja") + "ja" + + iex> DialectMapper.extract_base("EN-GB") + "en" + """ + def extract_base(locale) when is_binary(locale) do + locale + |> String.split("-") + |> List.first() + |> String.downcase() + end + + @doc """ + Converts base language code to default dialect. + + Uses predefined mapping for most common regional variants. + Falls back to base code if no mapping exists. + + ## Examples + + iex> DialectMapper.base_to_dialect("en") + "en-US" + + iex> DialectMapper.base_to_dialect("pt") + "pt-BR" + + iex> DialectMapper.base_to_dialect("ja") + "ja" + + iex> DialectMapper.base_to_dialect("xx") + "xx" + """ + def base_to_dialect(base_code) when is_binary(base_code) do + base_lower = String.downcase(base_code) + Map.get(@default_dialects, base_lower, base_lower) + end + + @doc """ + Resolves the full dialect code for a user visiting a base language URL. + + Resolution priority: + 1. User's saved preference (if authenticated and preference matches base code) + 2. Default dialect mapping for that base language + + ## Examples + + iex> user = %User{preferred_locale: "en-GB"} + iex> DialectMapper.resolve_dialect("en", user) + "en-GB" + + iex> user = %User{preferred_locale: "es-MX"} + iex> DialectMapper.resolve_dialect("en", user) + "en-US" # Preference doesn't match base, use default + + iex> DialectMapper.resolve_dialect("en", nil) + "en-US" + + iex> guest = %{some_field: "value"} + iex> DialectMapper.resolve_dialect("es", guest) + "es-ES" + + ## Security + + User preference only applied if it matches the requested base code. + This prevents users from forcing unintended locales via preference tampering. + + ## Graceful Degradation + + If user preference becomes invalid (dialect disabled, typo, etc.), + system falls back to default mapping. No crashes or errors. + """ + def resolve_dialect(base_code, user \\ nil) + + def resolve_dialect(base_code, %{preferred_locale: preferred} = _user) + when is_binary(preferred) do + # Verify user's preference matches the base code in URL + # Security: prevents locale preference injection attacks + if extract_base(preferred) == String.downcase(base_code) do + preferred + else + base_to_dialect(base_code) + end + end + + def resolve_dialect(base_code, _user) do + base_to_dialect(base_code) + end + + @doc """ + Validates if a base language code is supported. + + Checks if the default dialect for this base code exists in the + predefined language list. + + ## Examples + + iex> DialectMapper.valid_base_code?("en") + true + + iex> DialectMapper.valid_base_code?("ja") + true + + iex> DialectMapper.valid_base_code?("xx") + false + + iex> DialectMapper.valid_base_code?("en-US") + false # Not a base code (contains hyphen) + + ## Notes + + - Only validates base codes (2 letters) + - Full dialect codes will return false (use extract_base first) + - Checks against Languages.get_predefined_language/1 + """ + def valid_base_code?(base_code) when is_binary(base_code) do + # Only validate if it looks like a base code (2 letters, no hyphen) + if String.length(base_code) == 2 and not String.contains?(base_code, "-") do + dialect = base_to_dialect(base_code) + Languages.get_predefined_language(dialect) != nil + else + false + end + end + + @doc """ + Gets all available dialect codes for a base language. + + Searches the predefined language list for all dialects + matching the given base code. + + ## Examples + + iex> DialectMapper.dialects_for_base("en") + ["en-US", "en-GB", "en-CA", "en-AU"] + + iex> DialectMapper.dialects_for_base("es") + ["es-ES", "es-MX", "es-AR", "es-CO"] + + iex> DialectMapper.dialects_for_base("ja") + ["ja"] + + iex> DialectMapper.dialects_for_base("xx") + [] + + ## Use Cases + + - Populate user preference dropdown + - Admin analytics (dialects per base language) + - Migration tools (find affected users) + """ + def dialects_for_base(base_code) when is_binary(base_code) do + base_lower = String.downcase(base_code) + + Languages.get_available_languages() + |> Enum.filter(fn %{code: code} -> + extract_base(code) == base_lower + end) + |> Enum.map(& &1.code) + |> Enum.sort() + end + + @doc """ + Gets the default dialects map. + + Useful for debugging, testing, or documentation purposes. + + ## Examples + + iex> defaults = DialectMapper.default_dialects() + iex> defaults["en"] + "en-US" + + iex> defaults["pt"] + "pt-BR" + """ + def default_dialects, do: @default_dialects +end diff --git a/lib/phoenix_kit/users/auth.ex b/lib/phoenix_kit/users/auth.ex index c1b8fad46..17f80aa14 100644 --- a/lib/phoenix_kit/users/auth.ex +++ b/lib/phoenix_kit/users/auth.ex @@ -1031,6 +1031,33 @@ defmodule PhoenixKit.Users.Auth do end end + @doc """ + Updates user's preferred locale (dialect preference). + + This allows users to select specific language dialects (e.g., en-GB, en-US) + while URLs continue to use base codes (e.g., /en/). + + ## Examples + + iex> update_user_locale_preference(user, "en-GB") + {:ok, %User{preferred_locale: "en-GB"}} + + iex> update_user_locale_preference(user, "invalid") + {:error, %Ecto.Changeset{}} + """ + def update_user_locale_preference(%User{} = user, preferred_locale) + when is_binary(preferred_locale) do + case user + |> User.preferred_locale_changeset(%{preferred_locale: preferred_locale}) + |> Repo.update() do + {:ok, updated_user} -> + {:ok, updated_user} + + {:error, changeset} -> + {:error, changeset} + end + end + @doc """ Updates user custom fields. diff --git a/lib/phoenix_kit/users/auth/user.ex b/lib/phoenix_kit/users/auth/user.ex index b8b1e2646..909fc380d 100644 --- a/lib/phoenix_kit/users/auth/user.ex +++ b/lib/phoenix_kit/users/auth/user.ex @@ -45,6 +45,7 @@ defmodule PhoenixKit.Users.Auth.User do registration_region: String.t() | nil, registration_city: String.t() | nil, custom_fields: map() | nil, + preferred_locale: String.t() | nil, inserted_at: NaiveDateTime.t(), updated_at: NaiveDateTime.t() } @@ -65,6 +66,7 @@ defmodule PhoenixKit.Users.Auth.User do field :registration_region, :string field :registration_city, :string field :custom_fields, :map, default: %{} + field :preferred_locale, :string has_many :role_assignments, PhoenixKit.Users.RoleAssignment many_to_many :roles, PhoenixKit.Users.Role, join_through: PhoenixKit.Users.RoleAssignment @@ -276,6 +278,58 @@ defmodule PhoenixKit.Users.Auth.User do change(user, confirmed_at: nil) end + @doc """ + A user changeset for updating preferred locale/dialect. + + This allows authenticated users to select their preferred dialect variant + (e.g., en-GB instead of en-US) while URLs continue to show base codes. + + ## Validation + + - Format: Must match ~r/^[a-z]{2}(-[A-Z]{2})?$/ + - Existence: Must exist in predefined language list + - NULL allowed: Indicates "use system default" + + ## Examples + + iex> preferred_locale_changeset(user, %{preferred_locale: "en-GB"}) + #Ecto.Changeset<...> + + iex> preferred_locale_changeset(user, %{preferred_locale: nil}) + #Ecto.Changeset<...> # Clears preference, uses defaults + + iex> preferred_locale_changeset(user, %{preferred_locale: "invalid"}) + #Ecto.Changeset + """ + def preferred_locale_changeset(user, attrs) do + user + |> cast(attrs, [:preferred_locale]) + |> validate_locale_format() + |> validate_locale_exists() + end + + defp validate_locale_format(changeset) do + validate_change(changeset, :preferred_locale, fn :preferred_locale, locale -> + if locale && !Regex.match?(~r/^[a-z]{2}(-[A-Z]{2})?$/, locale) do + [preferred_locale: "must be a valid locale format (e.g., en-US, es-MX)"] + else + [] + end + end) + end + + defp validate_locale_exists(changeset) do + alias PhoenixKit.Modules.Languages + + validate_change(changeset, :preferred_locale, fn :preferred_locale, locale -> + if locale && !Languages.get_predefined_language(locale) do + [preferred_locale: "is not a recognized language code"] + else + [] + end + end) + end + @doc """ Verifies the password. diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index 3ab469fcb..b44427730 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -33,9 +33,22 @@ defmodule PhoenixKit.Utils.Routes do end defp determine_locale do - Process.get(:phoenix_kit_current_locale) || - Gettext.get_locale(PhoenixKitWeb.Gettext) || - "en" + alias PhoenixKit.Modules.Languages.DialectMapper + + # Check if we have base code in process dictionary (preferred) + case Process.get(:phoenix_kit_current_locale_base) do + nil -> + # Fall back to extracting base from full dialect + full_dialect = + Process.get(:phoenix_kit_current_locale) || + Gettext.get_locale(PhoenixKitWeb.Gettext) || + "en-US" + + DialectMapper.extract_base(full_dialect) + + base_code -> + base_code + end end @doc """ @@ -43,9 +56,18 @@ defmodule PhoenixKit.Utils.Routes do This function is specifically designed for use in component templates where the locale needs to be passed explicitly via assigns. + + Prefers base locale code for URL generation (current_locale_base), + falls back to extracting base from full dialect code (current_locale). """ def locale_aware_path(assigns, url_path) do - locale = assigns[:current_locale] || "en" + alias PhoenixKit.Modules.Languages.DialectMapper + + # Prefer base code, fall back to extracting from full dialect + locale = + assigns[:current_locale_base] || + DialectMapper.extract_base(assigns[:current_locale] || "en-US") + path(url_path, locale: locale) end diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index 2eb566f24..ea449dc4a 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -231,17 +231,38 @@ defmodule PhoenixKitWeb.Components.AdminNav do attr(:current_locale, :string, default: "en") def admin_language_dropdown(assigns) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Get admin languages from settings (separate from the language module) admin_languages = get_admin_languages() + # Extract base code from current locale for matching + current_base = DialectMapper.extract_base(assigns.current_locale) + + # Transform languages: code = base (for URLs), dialect = full (for preferences) + transformed_languages = + Enum.map(admin_languages, fn lang -> + dialect = lang["code"] + base = DialectMapper.extract_base(dialect) + + lang + |> Map.put("code", base) + |> Map.put("dialect", dialect) + end) + current_language = - Enum.find(admin_languages, &(&1["code"] == assigns.current_locale)) || - %{"code" => assigns.current_locale, "name" => String.upcase(assigns.current_locale)} + Enum.find(transformed_languages, &(&1["code"] == current_base)) || + %{ + "code" => current_base, + "dialect" => assigns.current_locale, + "name" => String.upcase(current_base) + } assigns = assigns - |> assign(:enabled_languages, admin_languages) + |> assign(:enabled_languages, transformed_languages) |> assign(:current_language, current_language) + |> assign(:current_base, current_base) ~H"""
@@ -257,17 +278,20 @@ defmodule PhoenixKitWeb.Components.AdminNav do <%= for language <- @enabled_languages do %>
  • - {get_language_flag(language["code"])} + {get_language_flag(language["dialect"])} {language["name"]} - <%= if language["code"] == @current_locale do %> + <%= if language["code"] == @current_base do %> <% end %> @@ -597,7 +621,9 @@ defmodule PhoenixKitWeb.Components.AdminNav do end defp locale_candidate?(locale) do - String.length(locale) in 2..6 and Regex.match?(~r/^[a-z]{2}(?:-[A-Z]{2})?$/, locale) + # Only match base language codes (2 letters, no hyphen) + # Full dialect codes (en-US) are no longer used in URLs + String.length(locale) == 2 and Regex.match?(~r/^[a-z]{2}$/i, locale) end # Helper function to get admin languages from settings @@ -632,18 +658,24 @@ defmodule PhoenixKitWeb.Components.AdminNav do end end - # Helper function to generate language switch URL - # This function handles both admin and frontend language switchers - defp generate_language_switch_url(current_path, new_locale) do + # Build URL with base code - expects base code directly (e.g., "en" not "en-US") + # Used by admin language dropdown where language["code"] is already the base code + defp build_locale_url(current_path, base_code) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Get valid language codes from both admin and frontend systems admin_languages = get_admin_languages() admin_language_codes = Enum.map(admin_languages, & &1["code"]) + admin_base_codes = Enum.map(admin_language_codes, &DialectMapper.extract_base/1) # Get frontend language codes from the Language Module frontend_language_codes = Languages.enabled_locale_codes() + frontend_base_codes = Enum.map(frontend_language_codes, &DialectMapper.extract_base/1) - # Accept language if it's valid in EITHER admin or frontend - valid_language_codes = (admin_language_codes ++ frontend_language_codes) |> Enum.uniq() + # Accept language if it's valid in EITHER admin or frontend (both full and base codes) + valid_language_codes = + (admin_language_codes ++ frontend_language_codes ++ admin_base_codes ++ frontend_base_codes) + |> Enum.uniq() # Remove PhoenixKit prefix if present normalized_path = String.replace_prefix(current_path || "", "/phoenix_kit", "") @@ -658,14 +690,28 @@ defmodule PhoenixKitWeb.Components.AdminNav do normalized_path end + ["", potential_locale] -> + if potential_locale in valid_language_codes do + "/" + else + normalized_path + end + _ -> normalized_path end - # Build the new URL with the new locale prefix + # Build the new URL with the base code locale prefix url_prefix = PhoenixKit.Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix - "#{base_prefix}/#{new_locale}#{clean_path}" + "#{base_prefix}/#{base_code}#{clean_path}" + end + + # Legacy helper - kept for backward compatibility + defp generate_language_switch_url(current_path, new_locale) do + alias PhoenixKit.Modules.Languages.DialectMapper + base_code = DialectMapper.extract_base(new_locale) + build_locale_url(current_path, base_code) end end diff --git a/lib/phoenix_kit_web/components/core/language_switcher.ex b/lib/phoenix_kit_web/components/core/language_switcher.ex index 16de76725..760a9372d 100644 --- a/lib/phoenix_kit_web/components/core/language_switcher.ex +++ b/lib/phoenix_kit_web/components/core/language_switcher.ex @@ -64,38 +64,72 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do attr(:hide_current, :boolean, default: false, doc: "Hide currently selected language from list") attr(:class, :string, default: "", doc: "Additional CSS classes") + attr(:current_path, :string, + default: nil, + doc: "Current path to preserve when switching languages" + ) + attr(:_language_update_key, :any, default: nil, doc: "Internal: forces re-render when languages change" ) def language_switcher_dropdown(assigns) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Auto-detect current_locale if not explicitly provided + # This might be a base code (en) or full dialect (en-US) locale = assigns.current_locale || Process.get(:phoenix_kit_current_locale) || Gettext.get_locale(PhoenixKitWeb.Gettext) || - "en" - - # Use provided languages or fetch from Language Module - # get_display_languages() returns configured languages or defaults (top 12) - languages = assigns.languages || Languages.get_display_languages() - - # Filter out current language if hide_current is enabled + "en-US" + + # Get enabled languages - these are full dialect codes with names + languages_config = assigns.languages || Languages.get_display_languages() + + # Transform to include both base code (for URLs) and dialect (for preference) + all_dialects = + languages_config + |> Enum.map(fn lang -> + dialect = lang["code"] + base = DialectMapper.extract_base(dialect) + flag = get_language_flag(dialect) + + %{ + "base_code" => base, + "dialect" => dialect, + "name" => lang["name"], + "flag" => flag + } + end) + |> Enum.sort_by(& &1["name"]) + + # Filter out current dialect if hide_current is enabled filtered_languages = if assigns.hide_current do - Enum.filter(languages, &(&1["code"] != locale)) + Enum.filter(all_dialects, &(&1["dialect"] != locale)) else - languages + all_dialects end + # Extract base code from current locale for matching + current_base = DialectMapper.extract_base(locale) + + # Find current language by full dialect code current_language = - Enum.find(filtered_languages, &(&1["code"] == locale)) || - %{"code" => locale, "name" => String.upcase(locale)} + Enum.find(all_dialects, &(&1["dialect"] == locale)) || + %{ + "base_code" => current_base, + "dialect" => locale, + "name" => String.upcase(locale), + "flag" => "🌐" + } assigns = assigns |> assign(:current_locale, locale) + |> assign(:current_base, current_base) |> assign(:languages, filtered_languages) |> assign(:current_language, current_language) @@ -113,18 +147,17 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do <%= for language <- @languages do %>
  • <%= if @show_flags do %> - {get_language_flag(language["code"])} + {language["flag"]} <% end %> <%= if @show_names do %>
    @@ -140,7 +173,7 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do <% else %>
    <% end %> - <%= if language["code"] == @current_locale do %> + <%= if language["base_code"] == @current_base do %> <% end %>
    @@ -179,53 +212,80 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do attr(:hide_current, :boolean, default: false, doc: "Hide currently selected language from list") attr(:class, :string, default: "", doc: "Additional CSS classes") + attr(:current_path, :string, + default: nil, + doc: "Current path to preserve when switching languages" + ) + def language_switcher_buttons(assigns) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Auto-detect current_locale if not explicitly provided + # This might be a base code (en) or full dialect (en-US) locale = assigns.current_locale || Process.get(:phoenix_kit_current_locale) || Gettext.get_locale(PhoenixKitWeb.Gettext) || - "en" - - # Use provided languages or fetch from Language Module - # get_display_languages() returns configured languages or defaults (top 12) - languages = assigns.languages || Languages.get_display_languages() - - # Filter out current language if hide_current is enabled + "en-US" + + # Get enabled languages - these are full dialect codes with names + languages_config = assigns.languages || Languages.get_display_languages() + + # Transform to include both base code (for URLs) and dialect (for preference) + all_dialects = + languages_config + |> Enum.map(fn lang -> + dialect = lang["code"] + base = DialectMapper.extract_base(dialect) + flag = get_language_flag(dialect) + + %{ + "base_code" => base, + "dialect" => dialect, + "name" => lang["name"], + "flag" => flag + } + end) + |> Enum.sort_by(& &1["name"]) + + # Extract base code from current locale for matching + current_base = DialectMapper.extract_base(locale) + + # Filter out current dialect if hide_current is enabled filtered_languages = if assigns.hide_current do - Enum.filter(languages, &(&1["code"] != locale)) + Enum.filter(all_dialects, &(&1["base_code"] != current_base)) else - languages + all_dialects end assigns = assigns |> assign(:current_locale, locale) + |> assign(:current_base, current_base) |> assign(:languages, filtered_languages) ~H"""
    <%= for language <- @languages do %> <%= if @show_flags do %> - {get_language_flag(language["code"])} + {language["flag"]} <% end %> <%= if @show_names do %> - {language["code"] |> String.upcase()} + {language["base_code"] |> String.upcase()} <% end %> <% end %> @@ -260,29 +320,57 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do attr(:hide_current, :boolean, default: false, doc: "Hide currently selected language from list") attr(:class, :string, default: "", doc: "Additional CSS classes") + attr(:current_path, :string, + default: nil, + doc: "Current path to preserve when switching languages" + ) + def language_switcher_inline(assigns) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Auto-detect current_locale if not explicitly provided + # This might be a base code (en) or full dialect (en-US) locale = assigns.current_locale || Process.get(:phoenix_kit_current_locale) || Gettext.get_locale(PhoenixKitWeb.Gettext) || - "en" - - # Use provided languages or fetch from Language Module - # get_display_languages() returns configured languages or defaults (top 12) - languages = assigns.languages || Languages.get_display_languages() - - # Filter out current language if hide_current is enabled + "en-US" + + # Get enabled languages - these are full dialect codes with names + languages_config = assigns.languages || Languages.get_display_languages() + + # Transform to include both base code (for URLs) and dialect (for preference) + all_dialects = + languages_config + |> Enum.map(fn lang -> + dialect = lang["code"] + base = DialectMapper.extract_base(dialect) + flag = get_language_flag(dialect) + + %{ + "base_code" => base, + "dialect" => dialect, + "name" => lang["name"], + "flag" => flag + } + end) + |> Enum.sort_by(& &1["name"]) + + # Extract base code from current locale for matching + current_base = DialectMapper.extract_base(locale) + + # Filter out current dialect if hide_current is enabled filtered_languages = if assigns.hide_current do - Enum.filter(languages, &(&1["code"] != locale)) + Enum.filter(all_dialects, &(&1["base_code"] != current_base)) else - languages + all_dialects end assigns = assigns |> assign(:current_locale, locale) + |> assign(:current_base, current_base) |> assign(:languages, filtered_languages) ~H""" @@ -293,24 +381,23 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do | <% end %> <%= if @show_flags do %> - {get_language_flag(language["code"])} + {language["flag"]} <% end %> <%= if @show_names do %> - {language["code"] |> String.upcase()} + {language["base_code"] |> String.upcase()} <% end %>
    @@ -327,10 +414,53 @@ defmodule PhoenixKitWeb.Components.Core.LanguageSwitcher do end end - # Helper function to generate language switch URL - # Current implementation returns home page with new locale - # Future enhancement: parse current path and preserve it when available via assigns - defp generate_language_url(_current_locale, new_locale) do - "/#{new_locale}" + # Generate URL with ONLY base code - no dialect, no query params + # This is the clean URL used in href attributes + # Example: generate_base_code_url("en", "/ru/admin/dashboard") => "/en/admin/dashboard" + defp generate_base_code_url(base_code, current_path) do + alias PhoenixKit.Utils.Routes + + # Extract base code from current path for proper path processing + current_base = extract_locale_from_path(current_path) + + # Remove locale from path + path_without_locale = get_path_without_locale(current_path, current_base) + + # Generate clean URL with base code only + Routes.path(path_without_locale, locale: base_code) + end + + # Extract the locale segment from a path + # /en/admin/dashboard => "en" + # /en-US/admin/dashboard => "en-US" + defp extract_locale_from_path(nil), do: nil + + defp extract_locale_from_path(path) do + case String.split(path, "/", parts: 3) do + ["", locale, _rest] -> locale + ["", locale] -> locale + _ -> nil + end + end + + # Helper function to extract path without locale prefix + # Handles: /en/admin/dashboard → /admin/dashboard + # Handles: /admin/dashboard → /admin/dashboard (no locale) + # Handles: nil → / (root) + defp get_path_without_locale(nil, _current_locale), do: "/" + + defp get_path_without_locale(current_path, current_locale) do + # Remove locale from path: /en/admin/dashboard → /admin/dashboard + case String.split(current_path, "/", parts: 3) do + ["", ^current_locale, rest] when is_binary(rest) -> + "/#{rest}" + + ["", ^current_locale] -> + "/" + + _ -> + # Path doesn't start with locale, return as-is + current_path + end end end diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index d807153da..7d4a513d1 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -1120,21 +1120,41 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do attr :current_locale, :string, default: "en" defp admin_language_switcher(assigns) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Only show if languages are enabled and there are enabled languages if Languages.enabled?() do enabled_languages = Languages.get_enabled_languages() # Only show if there are multiple languages (more than current one) if length(enabled_languages) > 1 do + # Extract base code from current locale for matching + current_base = DialectMapper.extract_base(assigns.current_locale) + + # Transform languages to use base codes as main identifier + # %{"code" => "en", "dialect" => "en-US", "name" => "English (US)", ...} + transformed_languages = + Enum.map(enabled_languages, fn lang -> + dialect = lang["code"] + base = DialectMapper.extract_base(dialect) + + lang + |> Map.put("code", base) + |> Map.put("dialect", dialect) + end) + current_language = - Enum.find(enabled_languages, &(&1["code"] == assigns.current_locale)) || - %{"code" => assigns.current_locale, "name" => String.upcase(assigns.current_locale)} + Enum.find(transformed_languages, &(&1["code"] == current_base)) || + %{ + "code" => current_base, + "dialect" => assigns.current_locale, + "name" => String.upcase(current_base) + } - other_languages = Enum.reject(enabled_languages, &(&1["code"] == assigns.current_locale)) + other_languages = Enum.reject(transformed_languages, &(&1["code"] == current_base)) assigns = assigns - |> assign(:enabled_languages, enabled_languages) |> assign(:current_language, current_language) |> assign(:other_languages, other_languages) @@ -1142,7 +1162,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
  • - {get_language_flag(language["code"])} + {get_language_flag(language["dialect"])} {language["name"]}
  • @@ -1183,32 +1206,50 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do end end - # Used in HEEX template - compiler cannot detect usage - def generate_language_switch_url(current_path, new_locale) do - # Get actual enabled language codes to properly detect locale prefixes + # Build URL with base code - expects base code directly (e.g., "en" not "en-US") + # Used by admin language switcher where language["code"] is already the base code + def build_locale_url(current_path, base_code) do + alias PhoenixKit.Modules.Languages.DialectMapper + + # Get enabled codes for locale detection in path enabled_language_codes = Languages.get_enabled_language_codes() + enabled_base_codes = Enum.map(enabled_language_codes, &DialectMapper.extract_base/1) # Remove PhoenixKit prefix if present normalized_path = String.replace_prefix(current_path || "", "/phoenix_kit", "") - # Remove existing locale prefix only if it matches actual language codes + # Remove existing locale prefix from path clean_path = case String.split(normalized_path, "/", parts: 3) do ["", potential_locale, rest] -> - if potential_locale in enabled_language_codes do + if potential_locale in enabled_language_codes or potential_locale in enabled_base_codes do "/" <> rest else normalized_path end + ["", potential_locale] -> + if potential_locale in enabled_language_codes or potential_locale in enabled_base_codes do + "/" + else + normalized_path + end + _ -> normalized_path end - # Build the new URL with the new locale prefix + # Build URL with base code url_prefix = PhoenixKit.Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix - "#{base_prefix}/#{new_locale}#{clean_path}" + "#{base_prefix}/#{base_code}#{clean_path}" + end + + # Legacy function - kept for backward compatibility + def generate_language_switch_url(current_path, new_locale) do + alias PhoenixKit.Modules.Languages.DialectMapper + base_code = DialectMapper.extract_base(new_locale) + build_locale_url(current_path, base_code) end end diff --git a/lib/phoenix_kit_web/controllers/blog_controller.ex b/lib/phoenix_kit_web/controllers/blog_controller.ex index 86a18af74..cc436b329 100644 --- a/lib/phoenix_kit_web/controllers/blog_controller.ex +++ b/lib/phoenix_kit_web/controllers/blog_controller.ex @@ -133,8 +133,22 @@ defmodule PhoenixKitWeb.BlogController do end defp valid_language?(code) when is_binary(code) do - # Check if it's an enabled language code - Languages.language_enabled?(code) + alias PhoenixKit.Modules.Languages.DialectMapper + + # Check if it's an enabled language code (full dialect like en-US) + # OR if it's a valid base code (like en) that maps to an enabled dialect + cond do + Languages.language_enabled?(code) -> + true + + # Check if it's a base code that maps to an enabled dialect + String.length(code) == 2 and not String.contains?(code, "-") -> + dialect = DialectMapper.base_to_dialect(code) + Languages.language_enabled?(dialect) + + true -> + false + end rescue _ -> false end @@ -291,8 +305,13 @@ defmodule PhoenixKitWeb.BlogController do end defp fetch_post(blog_slug, {:timestamp, date, time}, language) do + alias PhoenixKit.Modules.Languages.DialectMapper + + # Resolve base code to dialect if needed (e.g., "en" -> "en-US") + resolved_language = resolve_language_for_file(language) + # Build path for timestamp mode: blog/date/time/language.phk - path = "#{blog_slug}/#{date}/#{time}/#{language}.phk" + path = "#{blog_slug}/#{date}/#{time}/#{resolved_language}.phk" case Blogging.read_post(blog_slug, path) do {:ok, post} -> {:ok, post} @@ -300,11 +319,27 @@ defmodule PhoenixKitWeb.BlogController do end end + # Resolve a language code to the appropriate file language + # Handles both base codes (en) and full dialect codes (en-US) + defp resolve_language_for_file(language) do + alias PhoenixKit.Modules.Languages.DialectMapper + + if String.length(language) == 2 and not String.contains?(language, "-") do + # It's a base code - convert to default dialect + DialectMapper.base_to_dialect(language) + else + # Already a full dialect code + language + end + end + defp render_markdown(content) do Renderer.render_markdown(content) end defp build_translation_links(blog_slug, post, current_language) do + alias PhoenixKit.Modules.Languages.DialectMapper + # Get enabled languages enabled_languages = try do @@ -313,6 +348,9 @@ defmodule PhoenixKitWeb.BlogController do _ -> ["en"] end + # Extract base code from current language for comparison + current_base = DialectMapper.extract_base(current_language) + # Filter available languages to only show enabled ones languages = post.available_languages @@ -322,11 +360,13 @@ defmodule PhoenixKitWeb.BlogController do end) Enum.map(languages, fn lang -> + base_code = DialectMapper.extract_base(lang) + %{ - code: lang, + code: base_code, name: get_language_name(lang), - url: BlogHTML.build_post_url(blog_slug, post, lang), - current: lang == current_language + url: BlogHTML.build_post_url(blog_slug, post, base_code), + current: base_code == current_base } end) end diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 8e3cd39d9..8a2ff007c 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -122,9 +122,10 @@ defmodule PhoenixKitWeb.Integration do plug PhoenixKitWeb.Users.Auth, :phoenix_kit_validate_and_set_locale end - # Localized scope with generic locale pattern - # Accepts any 2-5 character language code format (e.g., "en", "zh-CN", "ar", etc.) - # Actual validation of whether the locale is supported happens in the validation plug + # Localized scope with flexible locale pattern + # Accepts both base codes (en, es) and full dialect codes (en-US, es-MX) + # Full dialect codes are automatically redirected to base codes by the validation plug + # This ensures backward compatibility with old URLs while enforcing base code standard scope "#{unquote(url_prefix)}/:locale", PhoenixKitWeb, Keyword.put(unquote(opts), :locale, ~r/^[a-z]{2}(?:-[A-Za-z0-9]{2,})?$/) do diff --git a/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex b/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex index beb3f6ef2..42ccae854 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex @@ -284,7 +284,9 @@ <%= if hide_flags do %> {gettext("Edit")} <% else %> - {flag} {String.upcase(lang_code)} + {flag} {lang_code + |> PhoenixKit.Modules.Languages.DialectMapper.extract_base() + |> String.upcase()} <% end %> <%!-- Layer 3: View button (only for published posts in enabled languages) --%> @@ -333,7 +335,9 @@ } > <.icon name="hero-plus" class="w-3 h-3 mr-1" /> - {flag} {String.upcase(lang_code)} + {flag} {lang_code + |> PhoenixKit.Modules.Languages.DialectMapper.extract_base() + |> String.upcase()}
    <% end %> diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex index 3f778a03d..0f7f7a153 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex @@ -365,16 +365,45 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do end defp resolve_language(available_languages, preferred_language) do + alias PhoenixKit.Modules.Languages.DialectMapper + code = - if preferred_language && preferred_language in available_languages do - preferred_language - else - select_display_language(available_languages, preferred_language) + cond do + # Direct match - preferred language exactly in available + preferred_language && preferred_language in available_languages -> + preferred_language + + # Base code match - try to find a dialect that matches the base code + # e.g., "en" matches "en-US" in available_languages + preferred_language && base_code?(preferred_language) -> + find_dialect_for_base(available_languages, preferred_language) || + select_display_language(available_languages, preferred_language) + + # Fallback to selection logic + true -> + select_display_language(available_languages, preferred_language) end {:ok, code} end + # Check if a code is a base code (2 letters, no hyphen) + defp base_code?(code) when is_binary(code) do + String.length(code) == 2 and not String.contains?(code, "-") + end + + defp base_code?(_), do: false + + # Find a dialect in available_languages that matches the given base code + defp find_dialect_for_base(available_languages, base_code) do + alias PhoenixKit.Modules.Languages.DialectMapper + base_lower = String.downcase(base_code) + + Enum.find(available_languages, fn lang -> + DialectMapper.extract_base(lang) == base_lower + end) + end + defp detect_available_languages(time_path) do case File.ls(time_path) do {:ok, files} -> diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex index fc78dcf9e..a6070ad32 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -333,7 +333,11 @@ <% true -> %> <% end %> {flag} - {String.upcase(lang_code)} + + {lang_code + |> PhoenixKit.Modules.Languages.DialectMapper.extract_base() + |> String.upcase()} + diff --git a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex index 60e7af8b7..bc44e9793 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex @@ -161,7 +161,9 @@ <%= for language <- insight.languages do %> - {String.upcase(language)} + {language + |> PhoenixKit.Modules.Languages.DialectMapper.extract_base() + |> String.upcase()} <% end %> diff --git a/lib/phoenix_kit_web/live/modules/posts/SPEC.md b/lib/phoenix_kit_web/live/modules/posts/SPEC.md new file mode 100644 index 000000000..1ecd693dd --- /dev/null +++ b/lib/phoenix_kit_web/live/modules/posts/SPEC.md @@ -0,0 +1,853 @@ +# Posts Module Specification + +**Version**: 1.0 +**Migration**: V28 +**Status**: 📋 Planning +**Created**: 2025-11-25 + +--- + +## 🎯 Overview + +Complete social posts system with media attachments, comments, likes, tags, user groups, and scheduled publishing for PhoenixKit. + +### Key Features + +- ✅ Multiple post types (post/snippet/repost) with different display layouts +- ✅ Multi-image uploads via PhoenixKit.Storage integration +- ✅ Unlimited nested comment threading +- ✅ User-created groups (Pinterest-style collections) +- ✅ Privacy controls (draft/public/unlisted/scheduled) +- ✅ Scheduled publishing via Oban +- ✅ Like/comment counters +- ✅ Hashtag tagging system +- ✅ User mentions/contributors +- ✅ View tracking (future release) + +--- + +## 📊 Database Schema (Migration V28) + +### 1. Posts Table (`phoenix_kit_posts`) + +**Purpose**: Main posts storage with type-specific layouts + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key (time-sortable) | +| `user_id` | bigint | FK → users (post owner) | +| `title` | string | Post title (max length via settings) | +| `sub_title` | string | Tagline/subtitle (max length via settings) | +| `content` | text | Post content (max length via settings) | +| `type` | string | post/snippet/repost (affects display layout) | +| `status` | string | draft/public/unlisted/scheduled | +| `scheduled_at` | utc_datetime_usec | When to auto-publish (nullable) | +| `published_at` | utc_datetime_usec | When made public (nullable) | +| `repost_url` | string | Source URL for reposts (nullable) | +| `slug` | string | SEO-friendly URL slug | +| `like_count` | integer | Denormalized counter (default: 0) | +| `comment_count` | integer | Denormalized counter (default: 0) | +| `view_count` | integer | Page views counter (default: 0) | +| `metadata` | jsonb | Type-specific flexible data | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `user_id`, `status`, `type`, `slug`, `scheduled_at`, `published_at` + +**Constraints**: +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) +- Unique: `slug` (per user or global - TBD) +- Check: `status IN ('draft', 'public', 'unlisted', 'scheduled')` +- Check: `type IN ('post', 'snippet', 'repost')` + +--- + +### 2. Post Media Junction (`phoenix_kit_post_media`) + +**Purpose**: Many-to-many relationship between posts and uploaded files + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `post_id` | uuid | FK → posts | +| `file_id` | uuid | FK → files (PhoenixKit.Storage) | +| `position` | integer | Display order (1, 2, 3...) | +| `caption` | text | Image caption/alt text (nullable) | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `file_id`, `position` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `file_id` → `phoenix_kit_files.id` (cascade delete) +- Unique: `(post_id, position)` - prevent duplicate ordering + +--- + +### 3. Likes Table (`phoenix_kit_post_likes`) + +**Purpose**: Track user likes on posts + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `post_id` | uuid | FK → posts | +| `user_id` | bigint | FK → users | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `user_id` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) +- Unique: `(post_id, user_id)` - one like per user per post + +--- + +### 4. Comments Table (`phoenix_kit_post_comments`) + +**Purpose**: Nested threaded comments (unlimited depth) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `post_id` | uuid | FK → posts | +| `user_id` | bigint | FK → users (commenter) | +| `parent_id` | uuid | FK → comments (nullable, for threading) | +| `content` | text | Comment text (required) | +| `status` | string | published/hidden/deleted/pending | +| `depth` | integer | Nesting level (0=top, 1=reply, 2=reply-to-reply...) | +| `like_count` | integer | Denormalized counter (default: 0, future feature) | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `user_id`, `parent_id`, `status`, `depth` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) +- FK: `parent_id` → `phoenix_kit_post_comments.id` (cascade delete) +- Check: `status IN ('published', 'hidden', 'deleted', 'pending')` + +--- + +### 5. Mentions/Contributors (`phoenix_kit_post_mentions`) + +**Purpose**: Tag users related to a post (helped create it, featured in it, etc.) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `post_id` | uuid | FK → posts | +| `user_id` | bigint | FK → users (mentioned user) | +| `mention_type` | string | contributor/mention | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `user_id`, `mention_type` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) +- Unique: `(post_id, user_id)` - one mention per user per post +- Check: `mention_type IN ('contributor', 'mention')` + +--- + +### 6. Tags Table (`phoenix_kit_post_tags`) + +**Purpose**: Hashtag system for categorization + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `name` | string | Display name (e.g., "Web Development") | +| `slug` | string | URL-safe slug (e.g., "web-development") | +| `usage_count` | integer | How many posts use this tag (default: 0) | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `slug`, `usage_count` + +**Constraints**: +- Unique: `slug` - case-insensitive unique slugs + +--- + +### 7. Post-Tag Junction (`phoenix_kit_post_tag_assignments`) + +**Purpose**: Many-to-many between posts and tags + +| Column | Type | Description | +|--------|------|-------------| +| `post_id` | uuid | FK → posts | +| `tag_id` | uuid | FK → tags | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `tag_id` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `tag_id` → `phoenix_kit_post_tags.id` (cascade delete) +- Unique: `(post_id, tag_id)` - no duplicate tags on same post + +--- + +### 8. User Groups Table (`phoenix_kit_post_groups`) + +**Purpose**: User-created collections to organize their posts (Pinterest-style boards) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `user_id` | bigint | FK → users (group owner) | +| `name` | string | Group name (e.g., "Travel Photos") | +| `slug` | string | URL-safe slug | +| `description` | text | Group description (nullable) | +| `cover_image_id` | uuid | FK → files (nullable, group thumbnail) | +| `post_count` | integer | Denormalized counter (default: 0) | +| `is_public` | boolean | Public groups visible to others (default: false) | +| `position` | integer | Manual ordering of user's groups | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `user_id`, `slug`, `is_public`, `position` + +**Constraints**: +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) +- FK: `cover_image_id` → `phoenix_kit_files.id` (set null on delete) +- Unique: `(user_id, slug)` - unique slug per user + +--- + +### 9. Post-Group Junction (`phoenix_kit_post_group_assignments`) + +**Purpose**: Many-to-many between posts and groups (posts can be in multiple groups) + +| Column | Type | Description | +|--------|------|-------------| +| `post_id` | uuid | FK → posts | +| `group_id` | uuid | FK → groups | +| `position` | integer | Manual ordering within group | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `group_id`, `position` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `group_id` → `phoenix_kit_post_groups.id` (cascade delete) +- Unique: `(post_id, group_id)` - post can't be in same group twice + +--- + +### 10. Views Table (`phoenix_kit_post_views`) + +**Purpose**: Analytics tracking (Phase 2 - future release) + +| Column | Type | Description | +|--------|------|-------------| +| `id` | UUIDv7 | Primary key | +| `post_id` | uuid | FK → posts | +| `user_id` | bigint | FK → users (nullable, logged-in only) | +| `ip_address` | string | Hashed IP for privacy (nullable) | +| `user_agent_hash` | string | Browser fingerprint (nullable) | +| `session_id` | string | Group views by session | +| `viewed_at` | utc_datetime_usec | When viewed | +| `date_added` | naive_datetime | Created timestamp | +| `date_modified` | naive_datetime | Updated timestamp | + +**Indexes**: `post_id`, `user_id`, `viewed_at`, `session_id` + +**Constraints**: +- FK: `post_id` → `phoenix_kit_posts.id` (cascade delete) +- FK: `user_id` → `phoenix_kit_users.id` (cascade delete) + +--- + +## ⚙️ Module Settings + +All settings stored in `phoenix_kit_settings` table via `PhoenixKit.Settings` context. + +### Content Limits (User Validation) + +| Setting Key | Default | Description | +|-------------|---------|-------------| +| `posts_max_media` | 10 | Max images per post | +| `posts_max_title_length` | 255 | Max title characters | +| `posts_max_subtitle_length` | 500 | Max subtitle characters | +| `posts_max_content_length` | 50000 | Max content characters | +| `posts_max_mentions` | 10 | Max users mentioned per post | +| `posts_max_tags` | 20 | Max hashtags per post | + +### Module Configuration + +| Setting Key | Default | Description | +|-------------|---------|-------------| +| `posts_enabled` | true | Enable/disable entire module | +| `posts_per_page` | 20 | Pagination limit | +| `posts_default_status` | "draft" | Default status for new posts | + +### Feature Toggles + +| Setting Key | Default | Description | +|-------------|---------|-------------| +| `posts_comments_enabled` | true | Global comment system toggle | +| `posts_likes_enabled` | true | Global likes system toggle | +| `posts_allow_scheduling` | true | Enable scheduled publishing | +| `posts_allow_groups` | true | Enable user groups feature | +| `posts_allow_reposts` | true | Enable repost type | +| `posts_seo_auto_slug` | true | Auto-generate URL slugs from titles | +| `posts_show_view_count` | true | Display view counts publicly | + +### Moderation + +| Setting Key | Default | Description | +|-------------|---------|-------------| +| `posts_require_approval` | false | All posts go to moderation queue | +| `posts_comment_moderation` | false | Require approval for comments | + +--- + +## 📁 File Structure + +``` +lib/phoenix_kit/posts/ +├── post.ex # Main post schema +├── post_media.ex # Post-media junction schema +├── post_like.ex # Like schema +├── post_comment.ex # Comment schema (with nesting) +├── post_mention.ex # Mention/contributor schema +├── post_tag.ex # Tag schema +├── post_tag_assignment.ex # Post-tag junction +├── post_group.ex # User group schema +├── post_group_assignment.ex # Post-group junction +├── post_view.ex # Analytics schema (future) +└── posts.ex # Context (business logic API) + +lib/phoenix_kit_web/live/modules/posts/ +├── SPEC.md # This file +├── README.md # User-facing documentation +├── posts.ex # List/index view +├── posts.html.heex # List template +├── edit.ex # Create/edit form +├── edit.html.heex # Form template +├── details.ex # Single post view +├── details.html.heex # Detail template +├── settings.ex # Module settings LiveView +├── settings.html.heex # Settings template +├── groups.ex # User's groups management +├── groups.html.heex # Groups list template +├── group_edit.ex # Create/edit group +├── group_edit.html.heex # Group form template +└── components/ + ├── post_card.ex # Post display (type-specific layouts) + ├── comment_thread.ex # Nested comments component + ├── like_button.ex # Like interaction + ├── tag_picker.ex # Tag selection/creation + └── group_selector.ex # Group assignment + +lib/phoenix_kit/migrations/postgres/ +└── v28.ex # Posts system migration +``` + +--- + +## 🎯 Implementation Checklist + +### ✅ Phase 1: Database & Schemas (Foundation) + +- [ ] **1.1 Create Migration V28** + - [ ] Create `lib/phoenix_kit/migrations/postgres/v28.ex` + - [ ] Define `up/1` function with all 10 tables + - [ ] Define `down/1` function with rollback logic + - [ ] Add all indexes and constraints + - [ ] Seed default module settings (16 settings) + - [ ] Update version tracking (comment on phoenix_kit table) + - [ ] Test migration up/down idempotency + +- [ ] **1.2 Create Post Schema** (`lib/phoenix_kit/posts/post.ex`) + - [ ] Define schema with UUIDv7 primary key + - [ ] Add all 16 fields with proper types + - [ ] Define associations (user, media, comments, likes, tags, groups, mentions) + - [ ] Create `changeset/2` with validations + - [ ] Add helper functions (published?, scheduled?, can_comment?, etc.) + - [ ] Add type specs (@type t :: ...) + - [ ] Add module documentation + +- [ ] **1.3 Create Post Media Schema** (`lib/phoenix_kit/posts/post_media.ex`) + - [ ] Define junction schema + - [ ] Add associations (post, file) + - [ ] Create changeset with position validation + - [ ] Add ordering helpers + +- [ ] **1.4 Create Post Like Schema** (`lib/phoenix_kit/posts/post_like.ex`) + - [ ] Define schema + - [ ] Add associations (post, user) + - [ ] Create changeset + - [ ] Add unique validation + +- [ ] **1.5 Create Post Comment Schema** (`lib/phoenix_kit/posts/post_comment.ex`) + - [ ] Define schema with parent_id for threading + - [ ] Add associations (post, user, parent, children) + - [ ] Create changeset with depth calculation + - [ ] Add helper functions (is_reply?, get_thread_root, etc.) + +- [ ] **1.6 Create Post Mention Schema** (`lib/phoenix_kit/posts/post_mention.ex`) + - [ ] Define schema + - [ ] Add associations (post, user) + - [ ] Create changeset with mention_type validation + +- [ ] **1.7 Create Post Tag Schema** (`lib/phoenix_kit/posts/post_tag.ex`) + - [ ] Define schema + - [ ] Add slug generation logic + - [ ] Create changeset with slug validation + - [ ] Add usage counter helpers + +- [ ] **1.8 Create Post Tag Assignment Schema** (`lib/phoenix_kit/posts/post_tag_assignment.ex`) + - [ ] Define junction schema + - [ ] Add associations (post, tag) + - [ ] Create changeset + +- [ ] **1.9 Create Post Group Schema** (`lib/phoenix_kit/posts/post_group.ex`) + - [ ] Define schema + - [ ] Add associations (user, cover_image, posts) + - [ ] Create changeset with slug validation + - [ ] Add helper functions (public?, user_owns?) + +- [ ] **1.10 Create Post Group Assignment Schema** (`lib/phoenix_kit/posts/post_group_assignment.ex`) + - [ ] Define junction schema + - [ ] Add associations (post, group) + - [ ] Create changeset with position validation + +- [ ] **1.11 Create Post View Schema** (`lib/phoenix_kit/posts/post_view.ex`) + - [ ] Define schema (for future analytics) + - [ ] Add associations (post, user) + - [ ] Create changeset + - [ ] Add deduplication logic + +- [ ] **1.12 Create Posts Context** (`lib/phoenix_kit/posts/posts.ex`) + - [ ] **CRUD Operations**: + - [ ] `create_post/2` - Create new post + - [ ] `update_post/2` - Update existing post + - [ ] `delete_post/1` - Delete post (cascade to all relations) + - [ ] `get_post!/1` - Get by ID with preloads + - [ ] `get_post_by_slug/1` - Get by slug + - [ ] **Query Helpers**: + - [ ] `list_posts/1` - Paginated list with filters + - [ ] `list_user_posts/2` - User's posts + - [ ] `list_public_posts/1` - Public posts only + - [ ] `search_posts/2` - Search by title/content + - [ ] `list_posts_by_tag/2` - Filter by tag + - [ ] `list_posts_by_group/2` - Filter by group + - [ ] **Counter Cache Updates**: + - [ ] `increment_like_count/1` + - [ ] `decrement_like_count/1` + - [ ] `increment_comment_count/1` + - [ ] `decrement_comment_count/1` + - [ ] `increment_view_count/1` + - [ ] **Like Operations**: + - [ ] `like_post/2` - User likes post + - [ ] `unlike_post/2` - User unlikes post + - [ ] `post_liked_by?/2` - Check if user liked post + - [ ] `list_post_likes/1` - Get all likes for post + - [ ] **Comment Operations**: + - [ ] `create_comment/2` - Add comment + - [ ] `update_comment/2` - Edit comment + - [ ] `delete_comment/1` - Delete comment + - [ ] `get_comment_tree/1` - Get nested comment structure + - [ ] `list_post_comments/2` - Paginated comments + - [ ] **Tag Operations**: + - [ ] `find_or_create_tag/1` - Get or create tag by name + - [ ] `parse_hashtags/1` - Extract hashtags from text + - [ ] `add_tags_to_post/2` - Assign tags to post + - [ ] `remove_tag_from_post/2` - Remove tag from post + - [ ] `list_popular_tags/1` - Top tags by usage + - [ ] **Group Operations**: + - [ ] `create_group/2` - Create user group + - [ ] `update_group/2` - Update group + - [ ] `delete_group/1` - Delete group + - [ ] `add_post_to_group/2` - Add post to group + - [ ] `remove_post_from_group/2` - Remove post from group + - [ ] `list_user_groups/1` - User's groups + - [ ] `reorder_groups/2` - Update group positions + - [ ] **Mention Operations**: + - [ ] `add_mention_to_post/3` - Mention user + - [ ] `remove_mention_from_post/2` - Remove mention + - [ ] `list_post_mentions/1` - Get mentioned users + - [ ] **Publishing Logic**: + - [ ] `publish_post/1` - Make post public + - [ ] `schedule_post/2` - Set scheduled publish time + - [ ] `process_scheduled_posts/0` - Publish scheduled posts (Oban job) + - [ ] `draft_post/1` - Revert to draft + - [ ] **Media Operations**: + - [ ] `attach_media/3` - Add image to post + - [ ] `detach_media/2` - Remove image from post + - [ ] `reorder_media/2` - Update image positions + - [ ] `list_post_media/1` - Get ordered images + +### ✅ Phase 2: Admin Interface (LiveView Pages) + +- [ ] **2.1 Posts List View** (`lib/phoenix_kit_web/live/modules/posts/posts.ex`) + - [ ] Create LiveView module + - [ ] Implement `mount/3` callback + - [ ] Implement `handle_params/3` for filters + - [ ] Add pagination (via `posts_per_page` setting) + - [ ] Add filters (type, status, group, tag, date range) + - [ ] Add search functionality + - [ ] Add bulk actions (publish, delete, move to group) + - [ ] Add statistics dashboard (total, drafts, scheduled) + - [ ] Create template (`posts.html.heex`) + +- [ ] **2.2 Post Create/Edit Form** (`lib/phoenix_kit_web/live/modules/posts/edit.ex`) + - [ ] Create LiveView module with `:new` and `:edit` actions + - [ ] Implement form changeset handling + - [ ] Add rich text editor for content + - [ ] Integrate media upload (multiple images) + - [ ] Add drag-drop image ordering + - [ ] Add tag input with autocomplete + - [ ] Add mention picker (user search) + - [ ] Add group multi-select + - [ ] Add type selector (post/snippet/repost) + - [ ] Add conditional fields per type + - [ ] Add status controls (draft/public/unlisted/scheduled) + - [ ] Add scheduled datetime picker + - [ ] Add live preview + - [ ] Implement validation against settings limits + - [ ] Create template (`edit.html.heex`) + +- [ ] **2.3 Post Details View** (`lib/phoenix_kit_web/live/modules/posts/details.ex`) + - [ ] Create LiveView module + - [ ] Implement type-specific layout rendering + - [ ] Add media gallery display + - [ ] Add nested comment thread + - [ ] Add like/unlike button + - [ ] Add edit/delete actions + - [ ] Add statistics display (views, likes, comments) + - [ ] Add share options + - [ ] Implement view tracking + - [ ] Create template (`details.html.heex`) + +- [ ] **2.4 Module Settings** (`lib/phoenix_kit_web/live/modules/posts/settings.ex`) + - [ ] Create LiveView module + - [ ] Load all 16 settings on mount + - [ ] Create form for content limits + - [ ] Create toggles for feature flags + - [ ] Create moderation controls + - [ ] Add live validation + - [ ] Add preview of limits + - [ ] Implement save handler + - [ ] Add success/error notifications + - [ ] Create template (`settings.html.heex`) + +- [ ] **2.5 Groups List View** (`lib/phoenix_kit_web/live/modules/posts/groups.ex`) + - [ ] Create LiveView module + - [ ] List user's groups with stats + - [ ] Add create/edit/delete actions + - [ ] Add drag-drop reordering + - [ ] Add cover image upload + - [ ] Add public/private toggle + - [ ] Add "view posts in group" navigation + - [ ] Create template (`groups.html.heex`) + +- [ ] **2.6 Group Edit Form** (`lib/phoenix_kit_web/live/modules/posts/group_edit.ex`) + - [ ] Create LiveView module + - [ ] Implement group form + - [ ] Add slug auto-generation + - [ ] Add cover image selector + - [ ] Add description editor + - [ ] Add public/private toggle + - [ ] Create template (`group_edit.html.heex`) + +### ✅ Phase 3: Components & Integration + +- [ ] **3.1 Post Card Component** (`components/post_card.ex`) + - [ ] Create Phoenix Component + - [ ] Define attrs (post, type, mode) + - [ ] Implement layout for type="post" + - [ ] Implement layout for type="snippet" + - [ ] Implement layout for type="repost" + - [ ] Add media gallery display + - [ ] Add like/comment counts + - [ ] Add action buttons + +- [ ] **3.2 Comment Thread Component** (`components/comment_thread.ex`) + - [ ] Create Phoenix Component + - [ ] Define attrs (comments, depth, max_depth) + - [ ] Implement recursive rendering + - [ ] Add collapse/expand functionality + - [ ] Add reply form + - [ ] Add like button (if enabled) + - [ ] Add edit/delete actions + - [ ] Add "load more" pagination + +- [ ] **3.3 Like Button Component** (`components/like_button.ex`) + - [ ] Create Phoenix Component + - [ ] Define attrs (post_id, liked, count) + - [ ] Implement like/unlike handler + - [ ] Add optimistic UI updates + - [ ] Add animation on like + - [ ] Handle authentication state + +- [ ] **3.4 Tag Picker Component** (`components/tag_picker.ex`) + - [ ] Create Phoenix Component + - [ ] Define attrs (selected_tags, on_change) + - [ ] Implement autocomplete search + - [ ] Add tag creation on-the-fly + - [ ] Add tag removal + - [ ] Enforce max tags limit + - [ ] Show popular tags + +- [ ] **3.5 Group Selector Component** (`components/group_selector.ex`) + - [ ] Create Phoenix Component + - [ ] Define attrs (groups, selected, user_id) + - [ ] Implement multi-select dropdown + - [ ] Add "create new group" inline + - [ ] Show group thumbnails + +- [ ] **3.6 Router Integration** + - [ ] Add posts routes to `PhoenixKitWeb.Integration.phoenix_kit_routes/0` + - [ ] Add authentication guards + - [ ] Add authorization checks (admin vs user) + - [ ] Configure route paths: + - [ ] `/posts` - list + - [ ] `/posts/new` - create + - [ ] `/posts/:id/edit` - edit + - [ ] `/posts/:id` - details + - [ ] `/posts/groups` - groups list + - [ ] `/posts/groups/new` - create group + - [ ] `/posts/groups/:id/edit` - edit group + - [ ] `/admin/posts/settings` - module settings + +- [ ] **3.7 Dashboard Integration** + - [ ] Add posts widget to `lib/phoenix_kit_web/live/dashboard.ex` + - [ ] Show total posts count + - [ ] Show recent activity (last 24h) + - [ ] Show scheduled posts count + - [ ] Add "Create Post" quick action + - [ ] Add "View All Posts" link + +- [ ] **3.8 Navigation Integration** + - [ ] Add "Posts" menu item to main admin nav + - [ ] Add submenu items (All Posts, My Groups, Settings) + - [ ] Add active state highlighting + +### ✅ Phase 4: Background Jobs & Features + +- [ ] **4.1 Scheduled Publishing Job** + - [ ] Create `lib/phoenix_kit/posts/jobs/publish_scheduled_posts.ex` + - [ ] Configure Oban worker + - [ ] Implement job logic (find scheduled posts where scheduled_at <= now) + - [ ] Update status to "public" + - [ ] Set published_at timestamp + - [ ] Schedule job to run every minute + - [ ] Add error handling and retries + - [ ] Add logging + +- [ ] **4.2 View Tracking** (Optional/Future) + - [ ] Create `lib/phoenix_kit/posts/jobs/track_view.ex` + - [ ] Implement async view recording + - [ ] Add session deduplication + - [ ] Add IP-based deduplication (hashed) + - [ ] Update view_count cache + - [ ] Configure sampling rate (via settings) + +- [ ] **4.3 Notification System** (Optional/Future) + - [ ] Create notification helpers + - [ ] Send notification when user is mentioned + - [ ] Send notification on comment replies + - [ ] Send notification on likes (optional) + - [ ] Add email notifications (via PhoenixKit.Mailer) + - [ ] Add in-app notifications + +- [ ] **4.4 Image Processing Integration** + - [ ] Verify PhoenixKit.Storage handles image uploads + - [ ] Ensure variant generation (thumbnail, medium, large) + - [ ] Add image optimization + - [ ] Add dimension validation + - [ ] Add file size validation + +### ✅ Phase 5: Documentation & Testing + +- [ ] **5.1 Create Module README** + - [ ] Create `lib/phoenix_kit_web/live/modules/posts/README.md` + - [ ] Document architecture overview + - [ ] Add usage examples + - [ ] Document configuration options + - [ ] Add troubleshooting section + - [ ] Document API reference + - [ ] Add screenshots (optional) + +- [ ] **5.2 Update Main Documentation** + - [ ] Update `CLAUDE.md` with posts module info + - [ ] Add to "Architecture" section + - [ ] Add to "Key File Structure" section + - [ ] Update installation guide + - [ ] Add to feature list in README.md + +- [ ] **5.3 Smoke Tests** + - [ ] Create `test/phoenix_kit/posts_test.exs` + - [ ] Test schema loading (all 10 schemas) + - [ ] Test context module loading + - [ ] Test basic CRUD operations + - [ ] Test settings integration + - [ ] Test associations (preloading) + - [ ] Verify no compilation warnings + +- [ ] **5.4 Migration Testing** + - [ ] Test migration up (V27 → V28) + - [ ] Test migration down (V28 → V27) + - [ ] Test idempotency (run up twice) + - [ ] Test with prefix + - [ ] Test on fresh database + - [ ] Verify all indexes created + - [ ] Verify all constraints work + +### ✅ Phase 6: Polish & Launch + +- [ ] **6.1 Code Quality** + - [ ] Run `mix format` + - [ ] Run `mix credo --strict` + - [ ] Run `mix dialyzer` + - [ ] Fix all warnings + - [ ] Add @doc to all public functions + - [ ] Add @spec to key functions + +- [ ] **6.2 User Experience** + - [ ] Add loading states + - [ ] Add error messages + - [ ] Add success notifications + - [ ] Add confirmation dialogs (delete actions) + - [ ] Add keyboard shortcuts + - [ ] Add mobile responsiveness + - [ ] Test accessibility + +- [ ] **6.3 Performance** + - [ ] Add database indexes for common queries + - [ ] Implement pagination everywhere + - [ ] Add query result caching (if needed) + - [ ] Optimize N+1 queries + - [ ] Add lazy loading for images + +- [ ] **6.4 Security** + - [ ] Add authorization checks (user owns post) + - [ ] Validate all user inputs + - [ ] Sanitize HTML in content + - [ ] Add CSRF protection (Phoenix default) + - [ ] Add rate limiting (via PhoenixKit.Users.RateLimiter) + - [ ] Add content moderation hooks + +- [ ] **6.5 Final Review** + - [ ] Test all user flows (create, edit, delete, publish, schedule) + - [ ] Test edge cases (max limits, empty states, errors) + - [ ] Review UI consistency with PhoenixKit design + - [ ] Verify settings all work correctly + - [ ] Test with different post types + - [ ] Test comment threading (deep nesting) + - [ ] Test group assignments + - [ ] Test media uploads + +--- + +## 🔑 Key Design Decisions + +1. **UUIDv7 for Posts** - Time-sortable IDs for better chronological indexing +2. **Denormalized Counters** - Cache likes/comments/views for performance (avoid COUNT queries) +3. **Unlimited Comment Nesting** - Depth field + recursive queries (Reddit-style threading) +4. **User-Specific Groups** - Each user manages their own collections (Pinterest model) +5. **Scheduled Publishing** - Via Oban background jobs (leverages V27 migration) +6. **Type-Specific Layouts** - Single schema, different UI per type (post/snippet/repost) +7. **Media Integration** - Use existing PhoenixKit.Storage system (no new file tables) +8. **Settings-Based Validation** - Dynamic limits from admin settings (flexible configuration) +9. **Soft Deletes** - Status field instead of hard deletes (enables moderation/recovery) +10. **SEO-Friendly** - Auto-generate slugs, support for scheduled publishing, unlisted posts + +--- + +## 🚀 Migration Path + +- **Current Version**: V27 (Oban + Storage System) +- **New Version**: V28 (Posts System) +- **Upgrade**: `mix phoenix_kit.update` (automatic) +- **Rollback**: Migration includes down/1 function +- **Safety**: Idempotent operations (safe to re-run) +- **Compatibility**: No breaking changes to existing features + +--- + +## 📝 Notes + +- **Phase 1** must be completed before Phase 2 (database foundation required) +- **Phase 2** and **Phase 3** can be developed in parallel (LiveView + Components) +- **Phase 4** can be added incrementally (background jobs are optional enhancements) +- **Phase 5** should be done continuously (documentation as you build) +- **Phase 6** is final polish before merging + +--- + +## 🎨 UI/UX Considerations + +### Post Types Display Differences + +- **Post**: Full-width layout, large media gallery, full content +- **Snippet**: Compact card, single image, truncated content with "read more" +- **Repost**: Original source attribution, embedded preview, quoted content + +### Comment Threading + +- Indent nested comments with visual thread lines +- Collapse deep threads (e.g., depth > 3) +- "Load more replies" button for performance +- Highlight OP (original poster) comments + +### Responsive Design + +- Mobile: Stack images, single column +- Tablet: 2-column grid for post list +- Desktop: 3-column grid, sidebar filters + +--- + +## ⚠️ Known Limitations & Future Enhancements + +### Current Limitations + +- View tracking not implemented (Phase 2) +- No real-time updates (WebSocket/Phoenix PubSub) +- No content moderation queue UI +- No spam detection +- No post drafts auto-save +- No post revisions/history + +### Future Enhancements + +- [ ] Real-time like/comment updates via PubSub +- [ ] Content moderation queue +- [ ] Spam detection (Akismet integration) +- [ ] Auto-save drafts (local storage) +- [ ] Post edit history +- [ ] Post bookmarks/favorites +- [ ] Advanced analytics dashboard +- [ ] Post recommendations +- [ ] RSS feeds per tag/group +- [ ] Export posts to PDF/Markdown + +--- + +## 📞 Support & Contribution + +For questions, issues, or contributions related to this module: + +1. Check `README.md` for usage documentation +2. Review this `SPEC.md` for architecture details +3. See `CLAUDE.md` in project root for development workflow +4. Follow PhoenixKit's contribution guidelines + +--- + +**Last Updated**: 2025-11-25 +**Specification Version**: 1.0 +**Target PhoenixKit Version**: 1.4.0+ diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 4dd575dd0..9db2c91d5 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -384,6 +384,7 @@ defmodule PhoenixKitWeb.Users.Auth do def on_mount(:phoenix_kit_mount_current_scope, _params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session) socket = check_maintenance_mode(socket) + socket = attach_locale_hook(socket) {:cont, socket} end @@ -418,6 +419,7 @@ defmodule PhoenixKitWeb.Users.Auth do def on_mount(:phoenix_kit_ensure_authenticated_scope, _params, session, socket) do socket = mount_phoenix_kit_current_scope(socket, session) socket = check_maintenance_mode(socket) + socket = attach_locale_hook(socket) scope = socket.assigns.phoenix_kit_current_scope cond do @@ -500,6 +502,7 @@ defmodule PhoenixKitWeb.Users.Auth do {:halt, socket} true -> + socket = attach_locale_hook(socket) {:cont, socket} end end @@ -530,6 +533,7 @@ defmodule PhoenixKitWeb.Users.Auth do {:halt, socket} Scope.admin?(scope) -> + socket = attach_locale_hook(socket) {:cont, socket} true -> @@ -545,6 +549,43 @@ defmodule PhoenixKitWeb.Users.Auth do end end + # Attach a hook to handle locale switching events from language switcher + defp attach_locale_hook(socket) do + # Check if hook is already attached to avoid duplicates + if socket.assigns[:phoenix_kit_locale_hook_attached?] do + socket + else + socket + |> Phoenix.Component.assign(:phoenix_kit_locale_hook_attached?, true) + |> Phoenix.LiveView.attach_hook( + :phoenix_kit_locale_handler, + :handle_event, + &handle_locale_event/3 + ) + end + end + + defp handle_locale_event("phoenix_kit_set_locale", %{"locale" => locale, "url" => url}, socket) do + save_user_locale_preference(socket.assigns, locale) + {:halt, Phoenix.LiveView.redirect(socket, to: url)} + end + + defp handle_locale_event(_event, _params, socket), do: {:cont, socket} + + defp save_user_locale_preference(%{phoenix_kit_current_user: %{} = user}, locale) + when not is_nil(user) do + Auth.update_user_locale_preference(user, locale) + end + + defp save_user_locale_preference(%{phoenix_kit_current_scope: scope}, locale) do + case Scope.user(scope) do + %{} = user -> Auth.update_user_locale_preference(user, locale) + _ -> :ok + end + end + + defp save_user_locale_preference(_assigns, _locale), do: :ok + defp set_routing_info(_params, url, socket) do %{path: path} = URI.parse(url) @@ -939,80 +980,202 @@ defmodule PhoenixKitWeb.Users.Auth do defp signed_in_path(_conn), do: "/" @doc """ - Validates and sets the locale from the URL path parameter. + Validates and sets the locale for the current request. + + This function is called as a plug in the router to validate locale codes in the URL path. + It implements PhoenixKit's simplified URL architecture: - Extracts the locale from the `:locale` path parameter, validates it against - enabled language codes from the database, and either sets the locale or - redirects to the default locale URL if invalid. + - URLs use base language codes (en, es, fr) for simplicity + - Full dialect codes (en-US, es-MX) are redirected to base codes (301) + - User preferences determine which dialect variant to use for translations + - Translation system uses full dialect codes internally + + ## Data Flow + + 1. Check if URL contains full dialect code → redirect to base + 2. Validate base code exists in predefined language list + 3. Resolve to full dialect using user preference or default mapping + 4. Set Gettext to full dialect for translations + 5. Store both base code (for URLs) and full dialect (for translations) ## Examples - # Valid locale in URL - conn = validate_and_set_locale(conn, []) # Sets Gettext locale + # Base code in URL (preferred format) + conn = validate_and_set_locale(conn, []) + # Sets: current_locale_base="en", current_locale="en-US" + + # Full dialect in URL (legacy/bookmarks) + conn = validate_and_set_locale(%{path_params: %{"locale" => "en-US"}}, []) + # Redirects 301 to: /en/... # Invalid locale in URL - conn = validate_and_set_locale(conn, []) # Redirects to default locale URL + conn = validate_and_set_locale(%{path_params: %{"locale" => "xx"}}, []) + # Redirects to default locale URL """ def validate_and_set_locale(conn, _opts) do + # Direct locale processing - dialect preferences are handled via LiveView events + process_locale(conn) + end + + # Locale processing logic + defp process_locale(conn) do + alias PhoenixKit.Modules.Languages.DialectMapper + case conn.path_params do %{"locale" => locale} when is_binary(locale) -> - # Accept any valid predefined language code - # get_predefined_language() checks the static list of 80+ languages - # regardless of whether the Language Module is enabled - if Languages.get_predefined_language(locale) do - # Valid language - set it and continue - Gettext.put_locale(PhoenixKitWeb.Gettext, locale) - assign(conn, :current_locale, locale) - else - # Invalid locale - redirect to default locale URL - redirect_invalid_locale(conn, locale) + cond do + # Check if this is a full dialect code (contains hyphen) → redirect to base + String.contains?(locale, "-") -> + redirect_to_base_locale(conn, locale) + + # Validate base code exists in predefined language list + DialectMapper.valid_base_code?(locale) -> + # Resolve to full dialect based on user preference + current_user = conn.assigns[:current_user] + full_dialect = DialectMapper.resolve_dialect(locale, current_user) + + # Set Gettext to full dialect for translations + Gettext.put_locale(PhoenixKitWeb.Gettext, full_dialect) + + # Store both base (for URLs) and full dialect (for translations) + # Also store in Process dictionary for LiveView mount functions + Process.put(:phoenix_kit_current_locale_base, locale) + Process.put(:phoenix_kit_current_locale, full_dialect) + + conn + |> assign(:current_locale_base, locale) + |> assign(:current_locale, full_dialect) + |> put_session(:phoenix_kit_locale_base, locale) + + # Invalid base code → redirect to default + true -> + redirect_invalid_locale(conn, locale) end _ -> - # No locale in URL - set default locale - Gettext.put_locale(PhoenixKitWeb.Gettext, "en") - assign(conn, :current_locale, "en") + # No locale in URL - set defaults + default_dialect = Languages.enabled_locale_codes() |> List.first() || "en-US" + default_base = DialectMapper.extract_base(default_dialect) + + Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) + Process.put(:phoenix_kit_current_locale_base, default_base) + Process.put(:phoenix_kit_current_locale, default_dialect) + + conn + |> assign(:current_locale_base, default_base) + |> assign(:current_locale, default_dialect) end end + @doc """ + Redirects full dialect code URLs to base language URLs (301 permanent). + + This function handles backward compatibility by redirecting old URLs with + full dialect codes (en-US, es-MX) to the new simplified base code URLs (en, es). + + Uses 301 Permanent redirect to tell browsers and search engines to update bookmarks + and indexed URLs. This is SEO-friendly and provides clean URL migration. + + ## Examples + + iex> redirect_to_base_locale(conn, "en-US") + # /phoenix_kit/en-US/admin/dashboard → /phoenix_kit/en/admin/dashboard + + iex> redirect_to_base_locale(conn, "es-MX") + # /phoenix_kit/es-MX/users?page=2 → /phoenix_kit/es/users?page=2 + + ## Preservation + + - Query parameters preserved + - URL fragments preserved + - Request method unchanged (GET → GET) + - Full path structure maintained + + ## Notes + + - Status code 301 (Permanent) tells clients to update bookmarks + - Halts conn pipeline (no further processing) + - Logged for monitoring migration patterns + """ + def redirect_to_base_locale(conn, full_dialect) do + alias PhoenixKit.Modules.Languages.DialectMapper + + base_code = DialectMapper.extract_base(full_dialect) + + # Replace first occurrence of full dialect with base code + # Handles: /phoenix_kit/en-US/admin → /phoenix_kit/en/admin + corrected_path = + String.replace( + conn.request_path, + "/#{full_dialect}/", + "/#{base_code}/", + global: false + ) + + # Handle case where dialect is at end of path + # Handles: /phoenix_kit/en-US → /phoenix_kit/en + corrected_path = + if String.ends_with?(conn.request_path, "/#{full_dialect}") do + String.replace_suffix(corrected_path, "/#{full_dialect}", "/#{base_code}") + else + corrected_path + end + + # Log redirect for monitoring (helps track migration patterns) + Logger.info(""" + [PhoenixKit Locale] Redirecting full dialect URL to base code + - Full dialect: #{full_dialect} + - Base code: #{base_code} + - Original path: #{conn.request_path} + - Corrected path: #{corrected_path} + """) + + conn + |> Phoenix.Controller.redirect(to: corrected_path, status: 301) + |> halt() + end + @doc """ Redirects invalid locale URLs to the default locale. Takes the current URL path and replaces the invalid locale with the default - locale ("en"), then redirects the user to the corrected URL. + locale base code, then redirects the user to the corrected URL. """ def redirect_invalid_locale(conn, invalid_locale) do - # Get the default locale (first enabled locale or "en") - default_locale = Languages.enabled_locale_codes() |> List.first() || "en" + alias PhoenixKit.Modules.Languages.DialectMapper - # If default is "en", remove locale prefix entirely; otherwise replace + # Get the default locale (first enabled locale or "en-US") + default_dialect = Languages.enabled_locale_codes() |> List.first() || "en-US" + default_base = DialectMapper.extract_base(default_dialect) + + # Replace invalid locale with default base code + # Always use base code format (en, es, fr) - no special handling for English corrected_path = - if default_locale == "en" do - # Remove the locale prefix completely for English - String.replace(conn.request_path, "/#{invalid_locale}/", "/", global: false) - else - # Replace with non-English default locale - String.replace(conn.request_path, "/#{invalid_locale}/", "/#{default_locale}/", - global: false - ) - end + String.replace( + conn.request_path, + "/#{invalid_locale}/", + "/#{default_base}/", + global: false + ) # If the invalid locale was at the end of the path, handle that case too corrected_path = if String.ends_with?(conn.request_path, "/#{invalid_locale}") do - if default_locale == "en" do - String.replace_suffix(corrected_path, "/#{invalid_locale}", "") - else - String.replace_suffix(corrected_path, "/#{invalid_locale}", "/#{default_locale}") - end + String.replace_suffix(corrected_path, "/#{invalid_locale}", "/#{default_base}") else corrected_path end # Log the invalid locale attempt for debugging - Logger.warning( - "Invalid locale '#{invalid_locale}' requested, redirecting to '#{default_locale}'. Path: #{conn.request_path}" - ) + Logger.warning(""" + [PhoenixKit Locale] Invalid locale requested, redirecting to default + - Invalid locale: #{invalid_locale} + - Default dialect: #{default_dialect} + - Default base: #{default_base} + - Original path: #{conn.request_path} + - Corrected path: #{corrected_path} + - Enabled locales: #{inspect(Languages.enabled_locale_codes())} + """) # Redirect to the corrected URL conn