All checks were successful
Build and Deploy / Build & Push Image (push) Successful in 33s
419 lines
14 KiB
Elixir
419 lines
14 KiB
Elixir
defmodule CobblemonUiWeb.DashboardLive do
|
|
use CobblemonUiWeb, :live_view
|
|
|
|
import CobblemonUiWeb.PokemonComponents
|
|
import CobblemonUiWeb.BattleComponents
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
if connected?(socket) do
|
|
:timer.send_interval(1000, self(), :tick)
|
|
end
|
|
|
|
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,
|
|
battle: nil,
|
|
selected_pokemon: nil,
|
|
tier_list: CobblemonUi.TierListScraper.get_tier_list(),
|
|
species_info: %{},
|
|
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} ->
|
|
battle = find_player_battle(uuid)
|
|
|
|
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,
|
|
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, battle: 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
|
|
|
|
@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
|
|
{:ok, list} -> list
|
|
end
|
|
|
|
socket =
|
|
if uuid = socket.assigns.selected_player do
|
|
battle = find_player_battle(uuid)
|
|
|
|
case CobblemonUi.CobblemonFS.get_player(uuid) do
|
|
{:ok, data} -> assign(socket, player_data: data, battle: battle, error: nil)
|
|
_ -> assign(socket, battle: battle)
|
|
end
|
|
else
|
|
socket
|
|
end
|
|
|
|
assign(socket, players: players)
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.app flash={@flash}>
|
|
<div class="h-screen flex flex-col">
|
|
<div class="max-w-4xl w-full mx-auto px-4 sm:px-6 lg:px-8 flex flex-col flex-1 min-h-0">
|
|
<%!-- Player picker --%>
|
|
<div :if={is_nil(@selected_player)} class="flex-1 overflow-y-auto py-8">
|
|
<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">Who are you?</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
:if={@players == []}
|
|
class="rounded-xl border border-base-300/30 bg-base-200/20 px-8 py-20 text-center"
|
|
>
|
|
<.icon name="hero-user-group" class="size-10 mx-auto mb-4 text-base-content/20" />
|
|
<p class="text-base-content/40 text-lg">No players found</p>
|
|
<p class="text-base-content/25 text-sm mt-1">Check the data directory</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
|
<.link
|
|
:for={player <- @players}
|
|
patch={~p"/player/#{player.uuid}"}
|
|
id={"player-#{player.uuid}"}
|
|
class="group flex items-center gap-3 rounded-xl border border-base-300/40 bg-base-200/20 px-5 py-4 hover:bg-base-200/50 hover:border-primary/30 hover:shadow-md hover:shadow-primary/5 transition-all duration-150"
|
|
>
|
|
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 group-hover:bg-primary/20 transition-colors">
|
|
<.icon name="hero-user" class="size-5 text-primary/70" />
|
|
</div>
|
|
<span class="text-base font-semibold text-base-content/80 group-hover:text-base-content truncate">
|
|
{player.name || "Unknown"}
|
|
</span>
|
|
<.icon
|
|
name="hero-chevron-right"
|
|
class="size-4 text-base-content/20 ml-auto shrink-0 group-hover:text-primary/40 transition-colors"
|
|
/>
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Player view --%>
|
|
<div :if={@selected_player} class="flex flex-col flex-1 min-h-0">
|
|
<%!-- Header --%>
|
|
<div class="shrink-0 py-4 border-b border-base-300/20 flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<.link
|
|
patch={~p"/"}
|
|
id="back-to-players"
|
|
class="w-10 h-10 rounded-xl bg-base-200/50 hover:bg-base-200 flex items-center justify-center transition-colors"
|
|
>
|
|
<.icon name="hero-arrow-left" class="size-5 text-base-content/50" />
|
|
</.link>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight text-base-content">
|
|
{player_name(@players, @selected_player)}
|
|
</h1>
|
|
<p class="text-sm text-base-content/50">Player Data Explorer</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Scrollable content --%>
|
|
<div class="flex-1 overflow-y-auto py-6">
|
|
<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/70">
|
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
<span class="text-sm font-medium">{@error}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div :if={@player_data}>
|
|
<%!-- Stats bar --%>
|
|
<div class="flex items-center gap-4 text-sm text-base-content/50 mb-6">
|
|
<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>
|
|
|
|
<%!-- Active battle --%>
|
|
<.battle_panel
|
|
:if={@battle}
|
|
battle={@battle}
|
|
player_id={@selected_player}
|
|
tier_list={@tier_list}
|
|
evolutions={opponent_evolutions(@battle, @selected_player)}
|
|
/>
|
|
|
|
<%!-- 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), not is_nil(pokemon) do %>
|
|
<.pokemon_card
|
|
pokemon={pokemon}
|
|
index={idx}
|
|
tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)}
|
|
/>
|
|
<% 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-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
|
<%= for {pokemon, idx} <- Enum.with_index(box.pokemon), not is_nil(pokemon) do %>
|
|
<% 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, skey, nil)}
|
|
types={Map.get(info, :types, [])}
|
|
evo_tiers={Map.get(info, :evo_tiers, [])}
|
|
/>
|
|
<% 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>
|
|
|
|
<%!-- Pokemon detail panel --%>
|
|
<.pokemon_detail
|
|
:if={@selected_pokemon}
|
|
pokemon={@selected_pokemon}
|
|
tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)}
|
|
/>
|
|
|
|
<%!-- Type Chart --%>
|
|
<.type_chart />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layouts.app>
|
|
"""
|
|
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 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
|
|
|
|
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)
|
|
|
|
# Pre-download sprites for this species and its evolutions
|
|
CobblemonUi.SpriteCache.ensure_sprite(species)
|
|
Enum.each(evolutions, &CobblemonUi.SpriteCache.ensure_sprite/1)
|
|
|
|
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
|
|
battle.actors
|
|
|> Enum.reject(fn actor -> actor.player_id == player_id end)
|
|
|> Enum.flat_map(fn actor -> actor.active_pokemon end)
|
|
|> Enum.reduce(%{}, fn poke, acc ->
|
|
species = String.downcase(poke.species || "")
|
|
|
|
if species != "" do
|
|
evolutions = CobblemonUi.EvolutionApi.get_evolutions(species)
|
|
Map.put(acc, species, evolutions)
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp find_player_battle(uuid) do
|
|
case CobblemonUi.BattlesApi.list_battles() do
|
|
{:ok, battles} ->
|
|
Enum.find(battles, fn battle ->
|
|
Enum.any?(battle.actors, fn actor -> actor.player_id == uuid end)
|
|
end)
|
|
|
|
_ ->
|
|
nil
|
|
end
|
|
end
|
|
end
|