defmodule CobblemonUiWeb.DashboardLive do use CobblemonUiWeb, :live_view @impl true def mount(_params, _session, socket) do if connected?(socket) do :timer.send_interval(1000, self(), :tick) unless File.exists?(CobblemonUi.TierListScraper.output_file()), do: send(self(), :scrape_tier_list) 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.load_tier_list(), 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) {:noreply, assign(socket, selected_player: uuid, player_data: data, battle: battle, 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, 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 def handle_event("refresh", _params, socket) do {:noreply, do_refresh(socket)} end @impl true def handle_info(:tick, socket) do {:noreply, do_refresh(socket)} end def handle_info(:scrape_tier_list, socket) do lv = self() Task.start(fn -> CobblemonUi.TierListScraper.run() send(lv, :reload_tier_list) end) {:noreply, socket} end def handle_info(:reload_tier_list, socket) do {:noreply, assign(socket, tier_list: CobblemonUi.TierListScraper.load_tier_list())} 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"""
<%!-- Player picker --%>
<.icon name="hero-cube-transparent" class="size-6 text-primary" />

Cobblemon

Who are you?

<.icon name="hero-user-group" class="size-10 mx-auto mb-4 text-base-content/20" />

No players found

Check the data directory

<.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" >
<.icon name="hero-user" class="size-5 text-primary/70" />
{player.name || "Unknown"} <.icon name="hero-chevron-right" class="size-4 text-base-content/20 ml-auto shrink-0 group-hover:text-primary/40 transition-colors" />
<%!-- Player view --%>
<%!-- Header --%>
<.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" />

{player_name(@players, @selected_player)}

Player Data Explorer

<.icon name="hero-exclamation-triangle" class="size-5" /> {@error}
<%!-- Stats bar --%>
{party_count(@player_data)} in party
{pc_count(@player_data)} in PC
<%!-- Active battle --%> <.battle_panel :if={@battle} battle={@battle} player_id={@selected_player} /> <%!-- View mode tabs --%>
<%!-- Party view --%>
<%= for {pokemon, idx} <- Enum.with_index(@player_data.party) do %> <.pokemon_card pokemon={pokemon} index={idx} tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)} /> <% end %>
<%!-- PC view --%>

Box {box.box + 1}

<%= for {pokemon, idx} <- Enum.with_index(box.pokemon) do %> <.pokemon_card pokemon={pokemon} index={pc_global_index(@player_data.pc, box.box, idx)} tier={Map.get(@tier_list, String.downcase(pokemon.species || ""), nil)} compact /> <% end %>
PC storage is empty
<%!-- Pokémon detail panel --%> <.pokemon_detail :if={@selected_pokemon} pokemon={@selected_pokemon} tier={Map.get(@tier_list, String.downcase(@selected_pokemon.species || ""), nil)} />
""" 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"""
Empty
""" end defp pokemon_card(assigns) do ~H"""

{@pokemon.species || "Unknown"}

Lv. {@pokemon.level || "?"}

<.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} compact={@compact} />
<.icon name="hero-sparkles" class={[ "text-warning", if(@compact, do: "size-3", else: "size-4") ]} />
{@pokemon.nature} {gender_symbol(@pokemon.gender)}
""" end attr :tier, :string, required: true attr :species, :string, required: true attr :compact, :boolean, default: false defp tier_badge(assigns) do ~H""" "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} """ end attr :pokemon, :map, required: true attr :tier, :string, default: nil defp pokemon_detail(assigns) do ~H"""
<%!-- Detail header --%>
<.icon name="hero-bolt" class="size-4 text-primary/70" />

{@pokemon.species || "Unknown"} <.tier_badge :if={@tier} tier={@tier} species={@pokemon.species} />

Level {@pokemon.level || "?"} · {String.capitalize(@pokemon.form || "default")} form

<%!-- Info Column --%>

Details

<.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} />
<%!-- Moves --%>

Moves

{format_move(move)}
No moves
<%!-- Stats Column --%>
<%!-- IVs --%>

IVs

<.stat_bar :for={{stat, val} <- stat_list(@pokemon.ivs)} label={format_stat(stat)} value={val} max={31} />
<%!-- EVs --%>

EVs ({ev_total(@pokemon.evs)}/510)

<.stat_bar :for={{stat, val} <- stat_list(@pokemon.evs)} label={format_stat(stat)} value={val} max={252} />
""" end attr :label, :string, required: true attr :value, :any, required: true defp stat_pill(assigns) do ~H"""

{@label}

{@value || "—"}

""" 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"""
{@label}
{@value}
""" end # --- Battle components --- attr :battle, :map, required: true attr :player_id, :string, required: true defp battle_panel(assigns) do ~H"""
Active Battle
<%= for actor <- @battle.actors do %>
<%= 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 %>

{actor.name}

{actor.type}

<%= for poke <- actor.active_pokemon do %>
{poke.species} Lv.{poke.level}
{poke.hp}/{poke.max_hp}
"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}%"} />
<% end %>
<% end %>
""" 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 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