updates
All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 33s

This commit is contained in:
2026-03-25 20:45:57 -06:00
parent 5184b60e9c
commit 0fbb03f7de
5 changed files with 729 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ defmodule CobblemonUi.Application do
CobblemonUi.CobblemonFS,
CobblemonUi.TierListScraper,
CobblemonUi.EvolutionApi,
CobblemonUi.PokeApi,
# Start to serve requests, typically the last entry
CobblemonUiWeb.Endpoint
]

View File

@@ -0,0 +1,89 @@
defmodule CobblemonUi.PokeApi do
@moduledoc """
Fetches and caches Pokémon type data from PokeAPI.
Uses an ETS table for fast lookups. Data is fetched once per species
and cached for the lifetime of the process.
"""
use GenServer
require Logger
@table :poke_api_types_cache
# ---------------------------------------------------------------------------
# Client API
# ---------------------------------------------------------------------------
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@doc """
Returns a list of type names (lowercase strings) for the given species.
Example: `get_types("bulbasaur")` → `["grass", "poison"]`
"""
@spec get_types(String.t()) :: [String.t()]
def get_types(species) when is_binary(species) do
key = String.downcase(species)
case :ets.lookup(@table, key) do
[{^key, types}] ->
types
[] ->
GenServer.call(__MODULE__, {:fetch_types, key}, 15_000)
end
end
# ---------------------------------------------------------------------------
# Server callbacks
# ---------------------------------------------------------------------------
@impl true
def init(_opts) do
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
{:ok, %{}}
end
@impl true
def handle_call({:fetch_types, species}, _from, state) do
result =
case :ets.lookup(@table, species) do
[{^species, types}] ->
types
[] ->
types = fetch_types(species)
:ets.insert(@table, {species, types})
types
end
{:reply, result, state}
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp fetch_types(species) do
url = "https://pokeapi.co/api/v2/pokemon/#{species}"
case Req.get(url) do
{:ok, %Req.Response{status: 200, body: %{"types" => types}}} ->
types
|> Enum.sort_by(fn t -> t["slot"] end)
|> Enum.map(fn t -> get_in(t, ["type", "name"]) end)
|> Enum.reject(&is_nil/1)
{:ok, %Req.Response{status: status}} ->
Logger.warning("[PokeApi] Type lookup failed for #{species}: HTTP #{status}")
[]
{:error, reason} ->
Logger.warning("[PokeApi] Type lookup failed for #{species}: #{inspect(reason)}")
[]
end
end
end

View File

@@ -0,0 +1,272 @@
defmodule CobblemonUi.TypeChart do
@moduledoc """
Pokemon type effectiveness data. Contains strengths (super effective),
weaknesses (weak to), resistances (not very effective against), and immunities.
"""
@types ~w(normal fire water grass electric ice fighting poison ground flying psychic bug rock ghost dragon dark steel fairy)
def types, do: @types
@doc """
Returns a map of type -> %{weak_to: [...], resists: [...], immune_to: [...]}
representing the defensive matchups for each type.
"""
def defensive_chart do
%{
"normal" => %{weak_to: ["fighting"], resists: [], immune_to: ["ghost"]},
"fire" => %{
weak_to: ["water", "ground", "rock"],
resists: ["fire", "grass", "ice", "bug", "steel", "fairy"],
immune_to: []
},
"water" => %{
weak_to: ["electric", "grass"],
resists: ["fire", "water", "ice", "steel"],
immune_to: []
},
"grass" => %{
weak_to: ["fire", "ice", "poison", "flying", "bug"],
resists: ["water", "electric", "grass", "ground"],
immune_to: []
},
"electric" => %{
weak_to: ["ground"],
resists: ["electric", "flying", "steel"],
immune_to: []
},
"ice" => %{weak_to: ["fire", "fighting", "rock", "steel"], resists: ["ice"], immune_to: []},
"fighting" => %{
weak_to: ["flying", "psychic", "fairy"],
resists: ["bug", "rock", "dark"],
immune_to: []
},
"poison" => %{
weak_to: ["ground", "psychic"],
resists: ["grass", "fighting", "poison", "bug", "fairy"],
immune_to: []
},
"ground" => %{
weak_to: ["water", "grass", "ice"],
resists: ["poison", "rock"],
immune_to: ["electric"]
},
"flying" => %{
weak_to: ["electric", "ice", "rock"],
resists: ["grass", "fighting", "bug"],
immune_to: ["ground"]
},
"psychic" => %{
weak_to: ["bug", "ghost", "dark"],
resists: ["fighting", "psychic"],
immune_to: []
},
"bug" => %{
weak_to: ["fire", "flying", "rock"],
resists: ["grass", "fighting", "ground"],
immune_to: []
},
"rock" => %{
weak_to: ["water", "grass", "fighting", "ground", "steel"],
resists: ["normal", "fire", "poison", "flying"],
immune_to: []
},
"ghost" => %{
weak_to: ["ghost", "dark"],
resists: ["poison", "bug"],
immune_to: ["normal", "fighting"]
},
"dragon" => %{
weak_to: ["ice", "dragon", "fairy"],
resists: ["fire", "water", "electric", "grass"],
immune_to: []
},
"dark" => %{
weak_to: ["fighting", "bug", "fairy"],
resists: ["ghost", "dark"],
immune_to: ["psychic"]
},
"steel" => %{
weak_to: ["fire", "fighting", "ground"],
resists: [
"normal",
"grass",
"ice",
"flying",
"psychic",
"bug",
"rock",
"dragon",
"steel",
"fairy"
],
immune_to: ["poison"]
},
"fairy" => %{
weak_to: ["poison", "steel"],
resists: ["fighting", "bug", "dark"],
immune_to: ["dragon"]
}
}
end
@doc """
Returns a map of type -> %{strong_against: [...], not_effective: [...], no_effect: [...]}
representing the offensive matchups for each type.
"""
def offensive_chart do
%{
"normal" => %{strong_against: [], not_effective: ["rock", "steel"], no_effect: ["ghost"]},
"fire" => %{
strong_against: ["grass", "ice", "bug", "steel"],
not_effective: ["fire", "water", "rock", "dragon"],
no_effect: []
},
"water" => %{
strong_against: ["fire", "ground", "rock"],
not_effective: ["water", "grass", "dragon"],
no_effect: []
},
"grass" => %{
strong_against: ["water", "ground", "rock"],
not_effective: ["fire", "grass", "poison", "flying", "bug", "dragon", "steel"],
no_effect: []
},
"electric" => %{
strong_against: ["water", "flying"],
not_effective: ["electric", "grass", "dragon"],
no_effect: ["ground"]
},
"ice" => %{
strong_against: ["grass", "ground", "flying", "dragon"],
not_effective: ["fire", "water", "ice", "steel"],
no_effect: []
},
"fighting" => %{
strong_against: ["normal", "ice", "rock", "dark", "steel"],
not_effective: ["poison", "flying", "psychic", "bug", "fairy"],
no_effect: ["ghost"]
},
"poison" => %{
strong_against: ["grass", "fairy"],
not_effective: ["poison", "ground", "rock", "ghost"],
no_effect: ["steel"]
},
"ground" => %{
strong_against: ["fire", "electric", "poison", "rock", "steel"],
not_effective: ["grass", "bug"],
no_effect: ["flying"]
},
"flying" => %{
strong_against: ["grass", "fighting", "bug"],
not_effective: ["electric", "rock", "steel"],
no_effect: []
},
"psychic" => %{
strong_against: ["fighting", "poison"],
not_effective: ["psychic", "steel"],
no_effect: ["dark"]
},
"bug" => %{
strong_against: ["grass", "psychic", "dark"],
not_effective: ["fire", "fighting", "poison", "flying", "ghost", "steel", "fairy"],
no_effect: []
},
"rock" => %{
strong_against: ["fire", "ice", "flying", "bug"],
not_effective: ["fighting", "ground", "steel"],
no_effect: []
},
"ghost" => %{
strong_against: ["psychic", "ghost"],
not_effective: ["dark"],
no_effect: ["normal"]
},
"dragon" => %{strong_against: ["dragon"], not_effective: ["steel"], no_effect: ["fairy"]},
"dark" => %{
strong_against: ["psychic", "ghost"],
not_effective: ["fighting", "dark", "fairy"],
no_effect: []
},
"steel" => %{
strong_against: ["ice", "rock", "fairy"],
not_effective: ["fire", "water", "electric", "steel"],
no_effect: []
},
"fairy" => %{
strong_against: ["fighting", "dragon", "dark"],
not_effective: ["fire", "poison", "steel"],
no_effect: []
}
}
end
@doc """
Returns the color class for a given type.
"""
def type_color("normal"), do: "bg-[#A8A77A]"
def type_color("fire"), do: "bg-[#EE8130]"
def type_color("water"), do: "bg-[#6390F0]"
def type_color("grass"), do: "bg-[#7AC74C]"
def type_color("electric"), do: "bg-[#F7D02C]"
def type_color("ice"), do: "bg-[#96D9D6]"
def type_color("fighting"), do: "bg-[#C22E28]"
def type_color("poison"), do: "bg-[#A33EA1]"
def type_color("ground"), do: "bg-[#E2BF65]"
def type_color("flying"), do: "bg-[#A98FF3]"
def type_color("psychic"), do: "bg-[#F95587]"
def type_color("bug"), do: "bg-[#A6B91A]"
def type_color("rock"), do: "bg-[#B6A136]"
def type_color("ghost"), do: "bg-[#735797]"
def type_color("dragon"), do: "bg-[#6F35FC]"
def type_color("dark"), do: "bg-[#705746]"
def type_color("steel"), do: "bg-[#B7B7CE]"
def type_color("fairy"), do: "bg-[#D685AD]"
def type_color(_), do: "bg-base-300"
@doc """
Returns the text color class for a given type.
"""
def type_text_color("normal"), do: "text-[#A8A77A]"
def type_text_color("fire"), do: "text-[#EE8130]"
def type_text_color("water"), do: "text-[#6390F0]"
def type_text_color("grass"), do: "text-[#7AC74C]"
def type_text_color("electric"), do: "text-[#F7D02C]"
def type_text_color("ice"), do: "text-[#96D9D6]"
def type_text_color("fighting"), do: "text-[#C22E28]"
def type_text_color("poison"), do: "text-[#A33EA1]"
def type_text_color("ground"), do: "text-[#E2BF65]"
def type_text_color("flying"), do: "text-[#A98FF3]"
def type_text_color("psychic"), do: "text-[#F95587]"
def type_text_color("bug"), do: "text-[#A6B91A]"
def type_text_color("rock"), do: "text-[#B6A136]"
def type_text_color("ghost"), do: "text-[#735797]"
def type_text_color("dragon"), do: "text-[#6F35FC]"
def type_text_color("dark"), do: "text-[#705746]"
def type_text_color("steel"), do: "text-[#B7B7CE]"
def type_text_color("fairy"), do: "text-[#D685AD]"
def type_text_color(_), do: "text-base-content/50"
@doc """
Returns a heroicon name for each type.
"""
def type_icon("normal"), do: "hero-minus-circle"
def type_icon("fire"), do: "hero-fire"
def type_icon("water"), do: "hero-beaker"
def type_icon("grass"), do: "hero-puzzle-piece"
def type_icon("electric"), do: "hero-bolt"
def type_icon("ice"), do: "hero-cloud"
def type_icon("fighting"), do: "hero-hand-raised"
def type_icon("poison"), do: "hero-beaker"
def type_icon("ground"), do: "hero-globe-americas"
def type_icon("flying"), do: "hero-paper-airplane"
def type_icon("psychic"), do: "hero-eye"
def type_icon("bug"), do: "hero-bug-ant"
def type_icon("rock"), do: "hero-cube"
def type_icon("ghost"), do: "hero-moon"
def type_icon("dragon"), do: "hero-star"
def type_icon("dark"), do: "hero-moon"
def type_icon("steel"), do: "hero-shield-check"
def type_icon("fairy"), do: "hero-sparkles"
def type_icon(_), do: "hero-question-mark-circle"
end

View File

@@ -24,9 +24,11 @@ defmodule CobblemonUiWeb.DashboardLive do
battle: nil,
selected_pokemon: nil,
tier_list: CobblemonUi.TierListScraper.get_tier_list(),
species_info: %{},
view_mode: :party,
loading: false,
error: nil
error: nil,
selected_type: nil
)}
end
@@ -36,14 +38,19 @@ defmodule CobblemonUiWeb.DashboardLive do
{:ok, data} ->
battle = find_player_battle(uuid)
{:noreply,
assign(socket,
selected_player: uuid,
player_data: data,
battle: battle,
selected_pokemon: nil,
error: nil
)}
socket =
assign(socket,
selected_player: uuid,
player_data: data,
battle: battle,
selected_pokemon: nil,
species_info: %{},
error: nil
)
if connected?(socket), do: send(self(), {:load_species_info, data})
{:noreply, socket}
{:error, :not_found} ->
{:noreply,
@@ -94,11 +101,24 @@ defmodule CobblemonUiWeb.DashboardLive do
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
end
def handle_event("select_type", %{"type" => ""}, socket) do
{:noreply, assign(socket, selected_type: nil)}
end
def handle_event("select_type", %{"type" => type}, socket) do
{:noreply, assign(socket, selected_type: type)}
end
@impl true
def handle_info(:tick, socket) do
{:noreply, do_refresh(socket)}
end
def handle_info({:load_species_info, player_data}, socket) do
species_info = compute_species_info(player_data, socket.assigns.tier_list)
{:noreply, assign(socket, species_info: species_info)}
end
defp do_refresh(socket) do
players =
case CobblemonUi.CobblemonFS.list_players() do
@@ -276,13 +296,16 @@ defmodule CobblemonUiWeb.DashboardLive do
<h3 class="text-sm font-semibold text-base-content/50 uppercase tracking-wider mb-3">
Box {box.box + 1}
</h3>
<div class="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-6 gap-2">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon), not is_nil(pokemon) do %>
<.pokemon_card
<% skey = String.downcase(pokemon.species || "") %>
<% info = Map.get(@species_info, skey, %{}) %>
<.pc_pokemon_card
pokemon={pokemon}
index={pc_global_index(@player_data.pc, box.box, idx)}
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
compact
tier={Map.get(@tier_list, skey, nil)}
types={Map.get(info, :types, [])}
evo_tiers={Map.get(info, :evo_tiers, [])}
/>
<% end %>
</div>
@@ -301,6 +324,9 @@ defmodule CobblemonUiWeb.DashboardLive do
pokemon={@selected_pokemon}
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
/>
<%!-- Type Chart --%>
<.type_chart selected_type={@selected_type} />
</div>
</div>
</div>
@@ -333,6 +359,38 @@ defmodule CobblemonUiWeb.DashboardLive do
end
end
defp compute_species_info(player_data, tier_list) do
all_pokemon =
(player_data.party ++ Enum.flat_map(player_data.pc, & &1.pokemon))
|> Enum.reject(&is_nil/1)
unique_species =
all_pokemon
|> Enum.map(&String.downcase(&1.species || ""))
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
unique_species
|> Task.async_stream(
fn species ->
types = CobblemonUi.PokeApi.get_types(species)
evolutions = CobblemonUi.EvolutionApi.get_evolutions(species)
evo_tiers =
Enum.map(evolutions, fn evo ->
%{species: evo, tier: Map.get(tier_list, evo)}
end)
{species, %{types: types, evo_tiers: evo_tiers}}
end,
max_concurrency: 10,
timeout: :infinity
)
|> Enum.reduce(%{}, fn {:ok, {species, info}}, acc ->
Map.put(acc, species, info)
end)
end
defp opponent_evolutions(nil, _player_id), do: %{}
defp opponent_evolutions(battle, player_id) do

View File

@@ -89,6 +89,76 @@ defmodule CobblemonUiWeb.PokemonComponents do
"""
end
attr :pokemon, :map, required: true
attr :index, :integer, required: true
attr :tier, :string, default: nil
attr :types, :list, default: []
attr :evo_tiers, :list, default: []
def pc_pokemon_card(assigns) do
~H"""
<div
phx-click="select_pokemon"
phx-value-index={@index}
class="group rounded-lg border border-base-300/30 bg-base-200/20 hover:bg-base-200/40 hover:border-primary/30 transition-all duration-150 cursor-pointer hover:shadow-md hover:shadow-primary/5 p-3"
>
<div class="flex items-start gap-2.5">
<%!-- Sprite --%>
<div class="w-12 h-12 rounded-lg bg-base-300/15 flex items-center justify-center shrink-0 overflow-hidden">
<img
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{String.downcase(@pokemon.species || "")}.png"}
alt={@pokemon.species}
class="w-10 h-10 object-contain drop-shadow-sm"
/>
</div>
<%!-- Info --%>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-xs font-semibold text-base-content/90 capitalize truncate group-hover:text-primary transition-colors">
{@pokemon.species || "Unknown"}
</span>
<.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} compact={true} />
</div>
<p class="text-[10px] text-base-content/40 mt-0.5">
Lv. {@pokemon.level || "?"}
</p>
<%!-- Types --%>
<div :if={@types != []} class="flex flex-wrap gap-1 mt-1.5">
<span
:for={type <- @types}
class={[
"inline-block text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded leading-none text-white/90",
CobblemonUi.TypeChart.type_color(type)
]}
>
{type}
</span>
</div>
</div>
</div>
<%!-- Evolution tiers --%>
<div :if={@evo_tiers != []} class="mt-2 pt-2 border-t border-base-content/5">
<div class="flex items-center gap-1 flex-wrap">
<span class="text-[9px] text-base-content/25 uppercase tracking-wider font-medium mr-0.5">
Evo
</span>
<%= for evo <- @evo_tiers do %>
<div class="flex items-center gap-1 rounded bg-base-300/15 px-1.5 py-0.5">
<img
src={"https://img.rankedboost.com/wp-content/plugins/k-Pokemon/assets/sprites-official/#{evo.species}.png"}
alt={evo.species}
class="w-4 h-4 object-contain opacity-70"
/>
<span class="text-[9px] text-base-content/40 capitalize">{evo.species}</span>
<.tier_badge :if={evo.tier} tier={evo.tier} species={evo.species} compact={true} />
</div>
<% end %>
</div>
</div>
</div>
"""
end
attr :tier, :string, required: true
attr :species, :string, required: true
attr :compact, :boolean, default: false
@@ -281,6 +351,232 @@ defmodule CobblemonUiWeb.PokemonComponents do
"""
end
# --- Type Chart Component ---
alias CobblemonUi.TypeChart
attr :selected_type, :string, default: nil
def type_chart(assigns) do
chart = TypeChart.defensive_chart()
offense = TypeChart.offensive_chart()
types = TypeChart.types()
assigns = assign(assigns, chart: chart, offense: offense, types: types)
~H"""
<div class="mt-8 rounded-xl border border-base-300/40 bg-base-200/30 overflow-hidden">
<div class="px-5 py-4 border-b border-base-300/30 flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
<.icon name="hero-table-cells" class="size-5 text-primary/80" />
</div>
<div>
<h3 class="font-bold text-base-content text-lg">Type Chart</h3>
<p class="text-xs text-base-content/40">
Tap a type to see its matchups
</p>
</div>
</div>
<%!-- Type picker grid --%>
<div class="px-5 pt-4 pb-2">
<div class="flex flex-wrap gap-1.5">
<button
:for={t <- @types}
phx-click="select_type"
phx-value-type={t}
class={[
"inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold capitalize transition-all duration-150 border cursor-pointer",
if(@selected_type == t,
do: "ring-2 ring-primary/60 border-primary/40 bg-base-100 shadow-md scale-105",
else:
"border-base-300/30 bg-base-200/40 hover:bg-base-200/70 hover:border-base-300/50 hover:shadow-sm"
)
]}
>
<span class={[
"inline-block w-2.5 h-2.5 rounded-full shrink-0",
type_bg(@selected_type == t, t)
]} />
{t}
</button>
</div>
</div>
<%!-- Selected type detail --%>
<%= if @selected_type do %>
<div class="px-5 py-4 animate-in fade-in duration-200">
<div class="flex items-center gap-3 mb-5">
<div class={[
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
TypeChart.type_color(@selected_type)
]}>
<.icon name={TypeChart.type_icon(@selected_type)} class="size-6 text-white drop-shadow" />
</div>
<div>
<h4 class="font-bold text-base-content capitalize text-xl">{@selected_type}</h4>
<p class="text-xs text-base-content/40">Defensive & Offensive matchups</p>
</div>
<button
phx-click="select_type"
phx-value-type=""
class="ml-auto btn btn-ghost btn-sm btn-circle hover:bg-base-300/50"
>
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<%!-- Defensive --%>
<div class="space-y-4">
<h5 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider flex items-center gap-1.5">
<.icon name="hero-shield-check" class="size-3.5" /> Defensive
</h5>
<%!-- Weak to --%>
<div>
<p class="text-[11px] text-error/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-exclamation-triangle" class="size-3" /> Weak to (2×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @chart[@selected_type].weak_to}
type_name={t}
/>
<span
:if={@chart[@selected_type].weak_to == []}
class="text-xs text-base-content/25 italic"
>
None
</span>
</div>
</div>
<%!-- Resists --%>
<div>
<p class="text-[11px] text-success/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-shield-check" class="size-3" /> Resists (½×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @chart[@selected_type].resists}
type_name={t}
/>
<span
:if={@chart[@selected_type].resists == []}
class="text-xs text-base-content/25 italic"
>
None
</span>
</div>
</div>
<%!-- Immune to --%>
<div :if={@chart[@selected_type].immune_to != []}>
<p class="text-[11px] text-info/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-no-symbol" class="size-3" /> Immune to (0×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @chart[@selected_type].immune_to}
type_name={t}
/>
</div>
</div>
</div>
<%!-- Offensive --%>
<div class="space-y-4">
<h5 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider flex items-center gap-1.5">
<.icon name="hero-bolt" class="size-3.5" /> Offensive
</h5>
<%!-- Super effective --%>
<div>
<p class="text-[11px] text-error/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-bolt" class="size-3" /> Super effective (2×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @offense[@selected_type].strong_against}
type_name={t}
/>
<span
:if={@offense[@selected_type].strong_against == []}
class="text-xs text-base-content/25 italic"
>
None
</span>
</div>
</div>
<%!-- Not very effective --%>
<div>
<p class="text-[11px] text-warning/80 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-shield-exclamation" class="size-3" /> Not very effective (½×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @offense[@selected_type].not_effective}
type_name={t}
/>
<span
:if={@offense[@selected_type].not_effective == []}
class="text-xs text-base-content/25 italic"
>
None
</span>
</div>
</div>
<%!-- No effect --%>
<div :if={@offense[@selected_type].no_effect != []}>
<p class="text-[11px] text-base-content/40 font-semibold uppercase tracking-wider mb-1.5 flex items-center gap-1">
<.icon name="hero-no-symbol" class="size-3" /> No effect (0×)
</p>
<div class="flex flex-wrap gap-1.5">
<.type_pill
:for={t <- @offense[@selected_type].no_effect}
type_name={t}
/>
</div>
</div>
</div>
</div>
</div>
<% else %>
<%!-- Compact overview when no type selected --%>
<div class="px-5 pb-4">
<p class="text-xs text-base-content/30 italic">Select a type above to view matchups</p>
</div>
<% end %>
</div>
"""
end
attr :type_name, :string, required: true
def type_pill(assigns) do
~H"""
<button
phx-click="select_type"
phx-value-type={@type_name}
class={[
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-semibold capitalize",
"border transition-all duration-150 cursor-pointer hover:scale-105 hover:shadow-sm",
TypeChart.type_color(@type_name),
"text-white border-white/10"
]}
>
<.icon name={TypeChart.type_icon(@type_name)} class="size-3 drop-shadow" />
{@type_name}
</button>
"""
end
defp type_bg(true, type), do: TypeChart.type_color(type)
defp type_bg(false, type), do: TypeChart.type_color(type) <> "/60"
# --- Helpers ---
defp gender_symbol("male"), do: ""