basics being displayed
This commit is contained in:
596
lib/cobblemon_ui_web/live/dashboard_live.ex
Normal file
596
lib/cobblemon_ui_web/live/dashboard_live.ex
Normal file
@@ -0,0 +1,596 @@
|
||||
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
|
||||
Reference in New Issue
Block a user