This commit is contained in:
84
lib/cobblemon_ui_web/live/battle_components.ex
Normal file
84
lib/cobblemon_ui_web/live/battle_components.ex
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
defmodule CobblemonUiWeb.BattleComponents do
|
||||||
|
use CobblemonUiWeb, :html
|
||||||
|
|
||||||
|
attr :battle, :map, required: true
|
||||||
|
attr :player_id, :string, required: true
|
||||||
|
|
||||||
|
def battle_panel(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="rounded-xl border border-error/30 bg-error/5 overflow-hidden mb-6">
|
||||||
|
<div class="px-5 py-3 border-b border-error/20 flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-error animate-pulse inline-block"></span>
|
||||||
|
<span class="text-sm font-semibold text-error">Active Battle</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-3 relative">
|
||||||
|
<div class="hidden md:flex absolute inset-y-0 left-1/2 -translate-x-1/2 items-center justify-center z-10 pointer-events-none">
|
||||||
|
<span class="text-base font-black text-base-content/20 bg-base-200/80 px-2 py-1 rounded-lg">
|
||||||
|
VS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<%= for actor <- @battle.actors do %>
|
||||||
|
<div class={[
|
||||||
|
"rounded-lg border p-3",
|
||||||
|
if(actor.player_id == @player_id,
|
||||||
|
do: "border-primary/25 bg-primary/5",
|
||||||
|
else: "border-warning/25 bg-warning/5"
|
||||||
|
)
|
||||||
|
]}>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<div class={[
|
||||||
|
"w-7 h-7 rounded-md flex items-center justify-center shrink-0",
|
||||||
|
if(actor.type == "player", do: "bg-primary/15", else: "bg-warning/15")
|
||||||
|
]}>
|
||||||
|
<%= if actor.type == "player" do %>
|
||||||
|
<.icon name="hero-user" class="size-3.5 text-primary" />
|
||||||
|
<% else %>
|
||||||
|
<.icon name="hero-cpu-chip" class="size-3.5 text-warning" />
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-base-content">{actor.name}</p>
|
||||||
|
<p class={[
|
||||||
|
"text-[10px] uppercase font-medium tracking-wide",
|
||||||
|
if(actor.type == "player", do: "text-primary/60", else: "text-warning/60")
|
||||||
|
]}>
|
||||||
|
{actor.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= for poke <- actor.active_pokemon do %>
|
||||||
|
<div class="rounded-md bg-base-100/50 border border-base-300/30 px-3 py-2">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-sm font-bold text-base-content">{poke.species}</span>
|
||||||
|
<span class="text-[10px] text-base-content/40 bg-base-300/40 px-1.5 py-0.5 rounded font-mono">
|
||||||
|
Lv.{poke.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-mono text-base-content/50">
|
||||||
|
{poke.hp}/{poke.max_hp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-1.5 rounded-full bg-base-300/50 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"h-full rounded-full transition-all",
|
||||||
|
cond do
|
||||||
|
poke.max_hp == 0 -> "bg-base-content/20"
|
||||||
|
poke.hp / poke.max_hp > 0.5 -> "bg-success"
|
||||||
|
poke.hp / poke.max_hp > 0.2 -> "bg-warning"
|
||||||
|
true -> "bg-error"
|
||||||
|
end
|
||||||
|
]}
|
||||||
|
style={"width: #{if poke.max_hp > 0, do: Float.round(poke.hp / poke.max_hp * 100, 1), else: 0}%"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
defmodule CobblemonUiWeb.DashboardLive do
|
defmodule CobblemonUiWeb.DashboardLive do
|
||||||
use CobblemonUiWeb, :live_view
|
use CobblemonUiWeb, :live_view
|
||||||
|
|
||||||
|
import CobblemonUiWeb.PokemonComponents
|
||||||
|
import CobblemonUiWeb.BattleComponents
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
@@ -313,7 +316,7 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Pokémon detail panel --%>
|
<%!-- Pokemon detail panel --%>
|
||||||
<.pokemon_detail
|
<.pokemon_detail
|
||||||
:if={@selected_pokemon}
|
:if={@selected_pokemon}
|
||||||
pokemon={@selected_pokemon}
|
pokemon={@selected_pokemon}
|
||||||
@@ -328,356 +331,6 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Components ---
|
|
||||||
|
|
||||||
attr :pokemon, :map, required: true
|
|
||||||
attr :index, :integer, required: true
|
|
||||||
attr :compact, :boolean, default: false
|
|
||||||
attr :tier, :string, default: nil
|
|
||||||
|
|
||||||
defp 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
|
|
||||||
|
|
||||||
defp 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={@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 :tier, :string, required: true
|
|
||||||
attr :species, :string, required: true
|
|
||||||
attr :compact, :boolean, default: false
|
|
||||||
|
|
||||||
defp 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
|
|
||||||
|
|
||||||
defp 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-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
||||||
<.icon name="hero-bolt" class="size-4 text-primary/70" />
|
|
||||||
</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={@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
|
|
||||||
|
|
||||||
defp 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
|
|
||||||
|
|
||||||
defp 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
|
|
||||||
|
|
||||||
# --- Battle components ---
|
|
||||||
|
|
||||||
attr :battle, :map, required: true
|
|
||||||
attr :player_id, :string, required: true
|
|
||||||
|
|
||||||
defp battle_panel(assigns) do
|
|
||||||
~H"""
|
|
||||||
<div class="rounded-xl border border-error/30 bg-error/5 overflow-hidden mb-6">
|
|
||||||
<div class="px-5 py-3 border-b border-error/20 flex items-center gap-2">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-error animate-pulse inline-block"></span>
|
|
||||||
<span class="text-sm font-semibold text-error">Active Battle</span>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-3 relative">
|
|
||||||
<div class="hidden md:flex absolute inset-y-0 left-1/2 -translate-x-1/2 items-center justify-center z-10 pointer-events-none">
|
|
||||||
<span class="text-base font-black text-base-content/20 bg-base-200/80 px-2 py-1 rounded-lg">
|
|
||||||
VS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<%= for actor <- @battle.actors do %>
|
|
||||||
<div class={[
|
|
||||||
"rounded-lg border p-3",
|
|
||||||
if(actor.player_id == @player_id,
|
|
||||||
do: "border-primary/25 bg-primary/5",
|
|
||||||
else: "border-warning/25 bg-warning/5"
|
|
||||||
)
|
|
||||||
]}>
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<div class={[
|
|
||||||
"w-7 h-7 rounded-md flex items-center justify-center shrink-0",
|
|
||||||
if(actor.type == "player", do: "bg-primary/15", else: "bg-warning/15")
|
|
||||||
]}>
|
|
||||||
<%= if actor.type == "player" do %>
|
|
||||||
<.icon name="hero-user" class="size-3.5 text-primary" />
|
|
||||||
<% else %>
|
|
||||||
<.icon name="hero-cpu-chip" class="size-3.5 text-warning" />
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-base-content">{actor.name}</p>
|
|
||||||
<p class={[
|
|
||||||
"text-[10px] uppercase font-medium tracking-wide",
|
|
||||||
if(actor.type == "player", do: "text-primary/60", else: "text-warning/60")
|
|
||||||
]}>
|
|
||||||
{actor.type}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%= for poke <- actor.active_pokemon do %>
|
|
||||||
<div class="rounded-md bg-base-100/50 border border-base-300/30 px-3 py-2">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<span class="text-sm font-bold text-base-content">{poke.species}</span>
|
|
||||||
<span class="text-[10px] text-base-content/40 bg-base-300/40 px-1.5 py-0.5 rounded font-mono">
|
|
||||||
Lv.{poke.level}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs font-mono text-base-content/50">{poke.hp}/{poke.max_hp}</span>
|
|
||||||
</div>
|
|
||||||
<div class="w-full h-1.5 rounded-full bg-base-300/50 overflow-hidden">
|
|
||||||
<div
|
|
||||||
class={[
|
|
||||||
"h-full rounded-full transition-all",
|
|
||||||
cond do
|
|
||||||
poke.max_hp == 0 -> "bg-base-content/20"
|
|
||||||
poke.hp / poke.max_hp > 0.5 -> "bg-success"
|
|
||||||
poke.hp / poke.max_hp > 0.2 -> "bg-warning"
|
|
||||||
true -> "bg-error"
|
|
||||||
end
|
|
||||||
]}
|
|
||||||
style={"width: #{if poke.max_hp > 0, do: Float.round(poke.hp / poke.max_hp * 100, 1), else: 0}%"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
defp party_count(%{party: party}), do: Enum.count(party, &(not is_nil(&1)))
|
defp party_count(%{party: party}), do: Enum.count(party, &(not is_nil(&1)))
|
||||||
@@ -694,38 +347,6 @@ defmodule CobblemonUiWeb.DashboardLive do
|
|||||||
offset + slot_index
|
offset + slot_index
|
||||||
end
|
end
|
||||||
|
|
||||||
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()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp ev_total(_), do: 0
|
|
||||||
|
|
||||||
defp player_name(players, uuid) do
|
defp player_name(players, uuid) do
|
||||||
case Enum.find(players, fn p -> p.uuid == uuid end) do
|
case Enum.find(players, fn p -> p.uuid == uuid end) do
|
||||||
%{name: name} when is_binary(name) -> name
|
%{name: name} when is_binary(name) -> name
|
||||||
|
|||||||
292
lib/cobblemon_ui_web/live/pokemon_components.ex
Normal file
292
lib/cobblemon_ui_web/live/pokemon_components.ex
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
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={@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 :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-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<.icon name="hero-bolt" class="size-4 text-primary/70" />
|
||||||
|
</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={@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
|
||||||
|
|
||||||
|
# --- 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
|
||||||
Reference in New Issue
Block a user