Files
cobblemon-ui/lib/cobblemon_ui_web/live/dashboard_live.ex

595 lines
20 KiB
Elixir

defmodule CobblemonUiWeb.DashboardLive do
use CobblemonUiWeb, :live_view
@impl true
def mount(_params, _session, socket) do
players =
case CobblemonUi.CobblemonFS.list_players() do
{:ok, list} -> list
end
{:ok,
assign(socket,
page_title: "Cobblemon Dashboard",
players: players,
selected_player: nil,
player_data: nil,
selected_pokemon: nil,
view_mode: :party,
loading: false,
error: nil
)}
end
@impl true
def handle_params(%{"uuid" => uuid}, _uri, socket) do
case CobblemonUi.CobblemonFS.get_player(uuid) do
{:ok, data} ->
{:noreply,
assign(socket,
selected_player: uuid,
player_data: data,
selected_pokemon: nil,
error: nil
)}
{:error, :not_found} ->
{:noreply,
assign(socket,
selected_player: uuid,
player_data: nil,
error: "Player not found"
)}
{:error, reason} ->
{:noreply,
assign(socket,
selected_player: uuid,
player_data: nil,
error: "Error loading player: #{inspect(reason)}"
)}
end
end
def handle_params(_params, _uri, socket) do
{:noreply, assign(socket, selected_player: nil, player_data: nil, selected_pokemon: nil)}
end
@impl true
def handle_event("select_pokemon", %{"index" => index_str}, socket) do
index = String.to_integer(index_str)
pokemon =
case socket.assigns.view_mode do
:party ->
Enum.at(socket.assigns.player_data.party, index)
:pc ->
socket.assigns.player_data.pc
|> Enum.flat_map(fn box -> box.pokemon end)
|> Enum.at(index)
end
{:noreply, assign(socket, selected_pokemon: pokemon)}
end
def handle_event("close_pokemon", _params, socket) do
{:noreply, assign(socket, selected_pokemon: nil)}
end
def handle_event("switch_view", %{"mode" => mode}, socket) do
{:noreply, assign(socket, view_mode: String.to_existing_atom(mode), selected_pokemon: nil)}
end
def handle_event("refresh", _params, socket) do
players =
case CobblemonUi.CobblemonFS.list_players() do
{:ok, list} -> list
end
socket =
if uuid = socket.assigns.selected_player do
case CobblemonUi.CobblemonFS.get_player(uuid) do
{:ok, data} -> assign(socket, player_data: data, error: nil)
_ -> socket
end
else
socket
end
{:noreply, assign(socket, players: players)}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="min-h-screen -mx-4 -my-20 sm:-mx-6 lg:-mx-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<%!-- Header --%>
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center">
<.icon name="hero-cube-transparent" class="size-6 text-primary" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight text-base-content">Cobblemon</h1>
<p class="text-sm text-base-content/50">Player Data Explorer</p>
</div>
</div>
<button
id="refresh-btn"
phx-click="refresh"
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300/50 transition-colors"
>
<.icon name="hero-arrow-path" class="size-4" /> Refresh
</button>
</div>
<div class="flex flex-col lg:flex-row gap-6">
<%!-- Sidebar: Player List --%>
<aside class="w-full lg:w-72 shrink-0">
<div class="rounded-xl border border-base-300/50 bg-base-200/30 backdrop-blur-sm">
<div class="px-4 py-3 border-b border-base-300/30">
<h2 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">
Players
</h2>
<p class="text-xs text-base-content/40 mt-0.5">
{length(@players)} found
</p>
</div>
<div class="p-2 max-h-[60vh] overflow-y-auto">
<div
:if={@players == []}
class="px-3 py-8 text-center text-sm text-base-content/40"
>
<.icon name="hero-user-group" class="size-8 mx-auto mb-2 opacity-30" />
<p>No players found</p>
<p class="text-xs mt-1">Check data directory</p>
</div>
<.link
:for={player <- @players}
patch={~p"/player/#{player.uuid}"}
class={[
"block px-3 py-2.5 rounded-lg transition-all duration-150 mb-1",
if(@selected_player == player.uuid,
do:
"bg-primary/15 text-primary border border-primary/20 shadow-sm shadow-primary/5",
else: "text-base-content/60 hover:bg-base-300/40 hover:text-base-content/80"
)
]}
>
<span class="text-sm font-medium block truncate">
{player.name || "Unknown"}
</span>
<span class="text-[10px] font-mono text-base-content/30 block truncate">
{player.uuid}
</span>
</.link>
</div>
</div>
</aside>
<%!-- Main Content --%>
<main class="flex-1 min-w-0">
<div
:if={@error}
class="rounded-xl border border-error/30 bg-error/10 px-5 py-4 mb-6"
>
<div class="flex items-center gap-2 text-error">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span class="text-sm font-medium">{@error}</span>
</div>
</div>
<%!-- Empty state --%>
<div
:if={is_nil(@selected_player)}
class="rounded-xl border border-base-300/30 bg-base-200/20 px-8 py-20 text-center"
>
<.icon
name="hero-arrow-left"
class="size-10 mx-auto mb-4 text-base-content/20"
/>
<p class="text-base-content/40 text-lg">Select a player to explore</p>
<p class="text-base-content/25 text-sm mt-1">
Choose from the sidebar to view their Pokémon
</p>
</div>
<%!-- Player data --%>
<div :if={@player_data}>
<%!-- Player header --%>
<div class="rounded-xl border border-base-300/40 bg-base-200/30 px-5 py-4 mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<.icon name="hero-user" class="size-5 text-primary/70" />
</div>
<div class="min-w-0">
<p class="text-base font-semibold text-base-content/90">
{player_name(@players, @player_data.uuid)}
</p>
<p class="text-xs font-mono text-base-content/40 truncate">
{@player_data.uuid}
</p>
</div>
<div class="ml-auto flex items-center gap-3 text-sm text-base-content/50">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-success/60" />
<span>{party_count(@player_data)} in party</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-info/60" />
<span>{pc_count(@player_data)} in PC</span>
</div>
</div>
</div>
</div>
<%!-- View mode tabs --%>
<div class="flex items-center gap-1 mb-5 p-1 rounded-lg bg-base-200/40 w-fit">
<button
id="tab-party"
phx-click="switch_view"
phx-value-mode="party"
class={[
"px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-150",
if(@view_mode == :party,
do: "bg-base-100 text-base-content shadow-sm",
else: "text-base-content/50 hover:text-base-content/70"
)
]}
>
Party
</button>
<button
id="tab-pc"
phx-click="switch_view"
phx-value-mode="pc"
class={[
"px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-150",
if(@view_mode == :pc,
do: "bg-base-100 text-base-content shadow-sm",
else: "text-base-content/50 hover:text-base-content/70"
)
]}
>
PC Storage
</button>
</div>
<%!-- Party view --%>
<div :if={@view_mode == :party}>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<%= for {pokemon, idx} <- Enum.with_index(@player_data.party) do %>
<.pokemon_card pokemon={pokemon} index={idx} />
<% end %>
</div>
</div>
<%!-- PC view --%>
<div :if={@view_mode == :pc}>
<div :for={box <- @player_data.pc} class="mb-6">
<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">
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon) do %>
<.pokemon_card
pokemon={pokemon}
index={pc_global_index(@player_data.pc, box.box, idx)}
compact
/>
<% end %>
</div>
</div>
<div
:if={@player_data.pc == []}
class="text-center py-12 text-base-content/30 text-sm"
>
PC storage is empty
</div>
</div>
<%!-- Pokémon detail panel --%>
<.pokemon_detail :if={@selected_pokemon} pokemon={@selected_pokemon} />
</div>
</main>
</div>
</div>
</div>
</Layouts.app>
"""
end
# --- Components ---
attr :pokemon, :map, required: true
attr :index, :integer, required: true
attr :compact, :boolean, default: false
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"""
<button
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 :if={@pokemon.shiny} class="shrink-0" title="Shiny">
<.icon
name="hero-sparkles"
class={[
"text-warning",
if(@compact, do: "size-3", else: "size-4")
]}
/>
</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>
</button>
"""
end
attr :pokemon, :map, required: true
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">
{@pokemon.species || "Unknown"}
<span
:if={@pokemon.shiny}
class="text-warning text-sm ml-1"
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
# --- Helpers ---
defp party_count(%{party: party}), do: Enum.count(party, &(not is_nil(&1)))
defp pc_count(%{pc: pc}),
do: Enum.sum(Enum.map(pc, fn b -> Enum.count(b.pokemon, &(not is_nil(&1))) end))
defp pc_global_index(boxes, current_box, slot_index) do
offset =
boxes
|> Enum.filter(fn b -> b.box < current_box end)
|> Enum.sum_by(fn b -> length(b.pokemon) end)
offset + slot_index
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
case Enum.find(players, fn p -> p.uuid == uuid end) do
%{name: name} when is_binary(name) -> name
_ -> "Unknown"
end
end
end