Files
cobblemon-ui/lib/cobblemon_ui_web/live/pokemon_components.ex
Alex Mickelson 694a10bdbe
All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 36s
updates
2026-03-25 21:10:53 -06:00

618 lines
22 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
defmodule CobblemonUiWeb.PokemonComponents do
use CobblemonUiWeb, :html
attr :pokemon, :map, required: true
attr :index, :integer, required: true
attr :compact, :boolean, default: false
attr :tier, :string, default: nil
def pokemon_card(%{pokemon: nil} = assigns) do
~H"""
<div class={[
"rounded-lg border border-base-300/20 bg-base-200/10 flex items-center justify-center",
if(@compact, do: "h-16", else: "h-24")
]}>
<span class="text-base-content/15 text-xs">Empty</span>
</div>
"""
end
def 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 text-left cursor-pointer",
"hover:shadow-md hover:shadow-primary/5",
if(@compact, do: "p-2.5", else: "p-3.5")
]}
>
<div class="flex items-start justify-between">
<div class="min-w-0">
<p class={[
"font-semibold text-base-content/90 capitalize truncate group-hover:text-primary transition-colors",
if(@compact, do: "text-xs", else: "text-sm")
]}>
{@pokemon.species || "Unknown"}
</p>
<p class={[
"text-base-content/40 mt-0.5",
if(@compact, do: "text-[10px]", else: "text-xs")
]}>
Lv. {@pokemon.level || "?"}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} compact={@compact} />
<div
:if={!@tier}
title="Tier data not downloaded"
class={[
"inline-flex items-center justify-center font-black rounded leading-none shrink-0",
"bg-base-300/20 text-base-content/25 ring-1 ring-base-300/30",
if(@compact, do: "text-[9px] w-4 h-4", else: "text-[10px] w-5 h-5")
]}
>
?
</div>
<div :if={@pokemon.shiny} title="Shiny">
<.icon
name="hero-sparkles"
class={[
"text-warning",
if(@compact, do: "size-3", else: "size-4")
]}
/>
</div>
</div>
</div>
<div :if={!@compact} class="flex items-center gap-2 mt-2">
<span
:if={@pokemon.nature}
class="inline-block text-[10px] px-1.5 py-0.5 rounded bg-base-300/40 text-base-content/50 capitalize"
>
{@pokemon.nature}
</span>
<span
:if={@pokemon.gender}
class={[
"text-[10px] font-bold",
gender_color(@pokemon.gender)
]}
>
{gender_symbol(@pokemon.gender)}
</span>
</div>
</div>
"""
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
def tier_badge(assigns) do
~H"""
<a
href={"https://rankedboost.com/pokemon/#{String.downcase(@species || "")}/"}
target="_blank"
rel="noopener noreferrer"
class={[
"inline-flex items-center justify-center font-black rounded leading-none shrink-0 hover:scale-110 transition-transform",
if(@compact, do: "text-[9px] w-4 h-4", else: "text-[10px] w-5 h-5"),
case @tier do
"S" -> "bg-red-500/20 text-red-400 ring-1 ring-red-500/30 hover:bg-red-500/30"
"A" -> "bg-orange-500/20 text-orange-400 ring-1 ring-orange-500/30 hover:bg-orange-500/30"
"B" -> "bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30 hover:bg-yellow-500/30"
"C" -> "bg-green-500/20 text-green-400 ring-1 ring-green-500/30 hover:bg-green-500/30"
"D" -> "bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30 hover:bg-blue-500/30"
_ -> "bg-base-300/30 text-base-content/40 ring-1 ring-base-300/40"
end
]}
>
{@tier}
</a>
"""
end
attr :pokemon, :map, required: true
attr :tier, :string, default: nil
def pokemon_detail(assigns) do
~H"""
<div class="mt-6 rounded-xl border border-base-300/40 bg-base-200/30 overflow-hidden">
<%!-- Detail header --%>
<div class="px-5 py-4 border-b border-base-300/30 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-16 h-16 rounded-lg bg-base-300/20 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-14 h-14 object-contain drop-shadow-sm"
/>
</div>
<div>
<h3 class="font-bold text-base-content capitalize text-lg flex items-center gap-2">
{@pokemon.species || "Unknown"}
<.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} />
<span
:if={!@tier}
class="inline-flex items-center gap-1 text-xs text-base-content/30 font-normal italic"
title="Tier data not downloaded"
>
<.icon name="hero-arrow-down-tray" class="size-3" /> tier unavailable
</span>
<span :if={@pokemon.shiny} class="text-warning text-sm" title="Shiny">★</span>
</h3>
<p class="text-xs text-base-content/40">
Level {@pokemon.level || "?"} · {String.capitalize(@pokemon.form || "default")} form
</p>
</div>
</div>
<button
id="close-pokemon-detail"
phx-click="close_pokemon"
class="btn btn-ghost btn-sm btn-circle hover:bg-base-300/50"
>
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
<div class="p-5 grid grid-cols-1 md:grid-cols-2 gap-6">
<%!-- Info Column --%>
<div class="space-y-4">
<div>
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
Details
</h4>
<div class="grid grid-cols-2 gap-2">
<.stat_pill label="Nature" value={@pokemon.nature} />
<.stat_pill label="Ability" value={@pokemon.ability} />
<.stat_pill label="Gender" value={@pokemon.gender} />
<.stat_pill label="Friendship" value={@pokemon.friendship} />
</div>
</div>
<%!-- Moves --%>
<div>
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
Moves
</h4>
<div class="space-y-1.5">
<div
:for={move <- @pokemon.moves || []}
class="px-3 py-1.5 rounded-md bg-base-300/20 border border-base-300/20 text-sm text-base-content/70 capitalize"
>
{format_move(move)}
</div>
<div :if={(@pokemon.moves || []) == []} class="text-xs text-base-content/30 italic">
No moves
</div>
</div>
</div>
</div>
<%!-- Stats Column --%>
<div class="space-y-4">
<%!-- IVs --%>
<div :if={@pokemon.ivs}>
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
IVs
</h4>
<div class="space-y-1.5">
<.stat_bar
:for={{stat, val} <- stat_list(@pokemon.ivs)}
label={format_stat(stat)}
value={val}
max={31}
/>
</div>
</div>
<%!-- EVs --%>
<div :if={@pokemon.evs}>
<h4 class="text-xs font-semibold text-base-content/40 uppercase tracking-wider mb-2">
EVs
<span class="text-base-content/25 font-normal ml-1">
({ev_total(@pokemon.evs)}/510)
</span>
</h4>
<div class="space-y-1.5">
<.stat_bar
:for={{stat, val} <- stat_list(@pokemon.evs)}
label={format_stat(stat)}
value={val}
max={252}
/>
</div>
</div>
</div>
</div>
</div>
"""
end
attr :label, :string, required: true
attr :value, :any, required: true
def stat_pill(assigns) do
~H"""
<div class="px-3 py-2 rounded-lg bg-base-300/15 border border-base-300/15">
<p class="text-[10px] text-base-content/35 uppercase tracking-wider">{@label}</p>
<p class="text-sm text-base-content/80 capitalize mt-0.5">{@value || "—"}</p>
</div>
"""
end
attr :label, :string, required: true
attr :value, :integer, required: true
attr :max, :integer, required: true
def stat_bar(assigns) do
pct = if assigns.max > 0, do: min(assigns.value / assigns.max * 100, 100), else: 0
color =
cond do
pct >= 90 -> "bg-success"
pct >= 60 -> "bg-info"
pct >= 30 -> "bg-warning"
true -> "bg-error/70"
end
assigns = assign(assigns, pct: pct, color: color)
~H"""
<div class="flex items-center gap-2">
<span class="text-[10px] text-base-content/40 w-10 uppercase tracking-wider shrink-0">
{@label}
</span>
<div class="flex-1 h-2 rounded-full bg-base-300/30 overflow-hidden">
<div
class={[@color, "h-full rounded-full transition-all duration-500"]}
style={"width: #{@pct}%"}
/>
</div>
<span class="text-xs text-base-content/50 w-8 text-right tabular-nums font-mono">
{@value}
</span>
</div>
"""
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.5 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-flex items-center justify-center w-5 h-5 rounded-full shrink-0 p-0.5",
TypeChart.type_color(t)
]}>
<img src={TypeChart.type_icon_path(t)} alt={t} class="w-3.5 h-3.5" />
</span>
{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)
]}>
<img
src={TypeChart.type_icon_path(@selected_type)}
alt={@selected_type}
class="w-7 h-7 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"
]}
>
<img
src={TypeChart.type_icon_path(@type_name)}
alt={@type_name}
class="w-3.5 h-3.5 drop-shadow"
/>
{@type_name}
</button>
"""
end
# --- Helpers ---
defp gender_symbol("male"), do: ""
defp gender_symbol("female"), do: ""
defp gender_symbol(_), do: ""
defp gender_color("male"), do: "text-info"
defp gender_color("female"), do: "text-error"
defp gender_color(_), do: "text-base-content/40"
defp format_move(move) when is_binary(move), do: String.replace(move, "_", " ")
defp format_move(_), do: ""
defp format_stat(:hp), do: "HP"
defp format_stat(:attack), do: "ATK"
defp format_stat(:defense), do: "DEF"
defp format_stat(:special_attack), do: "SPA"
defp format_stat(:special_defense), do: "SPD"
defp format_stat(:speed), do: "SPE"
defp format_stat(other), do: to_string(other)
defp stat_list(stats) when is_map(stats) do
[:hp, :attack, :defense, :special_attack, :special_defense, :speed]
|> Enum.map(fn key -> {key, Map.get(stats, key, 0)} end)
end
defp stat_list(_), do: []
defp ev_total(evs) when is_map(evs), do: evs |> Map.values() |> Enum.sum()
defp ev_total(_), do: 0
end