mirror of
https://github.com/wanderer-industries/wanderer
synced 2026-02-08 23:16:08 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd018abfe | ||
|
|
f430f74e98 | ||
|
|
9e146d1117 | ||
|
|
0a707fb423 | ||
|
|
8cda76cc43 | ||
|
|
89d7df0ba2 |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -2,6 +2,24 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.94.0](https://github.com/wanderer-industries/wanderer/compare/v1.93.0...v1.94.0) (2026-02-08)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* administration: Added registered characters admin view with cort/ally info, sort and filter options
|
||||
|
||||
## [v1.93.0](https://github.com/wanderer-industries/wanderer/compare/v1.92.0...v1.93.0) (2026-02-08)
|
||||
|
||||
|
||||
|
||||
|
||||
### Features:
|
||||
|
||||
* subscriptions: Added an ability to withdraw from map to user balance
|
||||
|
||||
## [v1.92.0](https://github.com/wanderer-industries/wanderer/compare/v1.91.11...v1.92.0) (2026-01-14)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ defmodule WandererApp.Api.Character do
|
||||
define(:active_by_user,
|
||||
action: :active_by_user
|
||||
)
|
||||
|
||||
define(:admin_all, action: :admin_all)
|
||||
end
|
||||
|
||||
actions do
|
||||
@@ -69,6 +71,10 @@ defmodule WandererApp.Api.Character do
|
||||
filter(expr(user_id == ^arg(:user_id) and deleted == false))
|
||||
end
|
||||
|
||||
read :admin_all do
|
||||
prepare build(load: [:user])
|
||||
end
|
||||
|
||||
read :last_active do
|
||||
argument(:from, :utc_datetime, allow_nil?: false)
|
||||
|
||||
|
||||
@@ -256,6 +256,11 @@ defmodule WandererAppWeb.Layouts do
|
||||
Admin
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={@show_admin}>
|
||||
<.link navigate="/admin/characters">
|
||||
Characters
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={@show_admin}>
|
||||
<.link navigate="/admin/errors">
|
||||
Errors
|
||||
|
||||
157
lib/wanderer_app_web/live/admin/admin_characters_live.ex
Normal file
157
lib/wanderer_app_web/live/admin/admin_characters_live.ex
Normal file
@@ -0,0 +1,157 @@
|
||||
defmodule WandererAppWeb.AdminCharactersLive do
|
||||
@moduledoc """
|
||||
Admin LiveView for viewing all registered characters on the server.
|
||||
"""
|
||||
use WandererAppWeb, :live_view
|
||||
|
||||
alias Phoenix.LiveView.AsyncResult
|
||||
|
||||
@characters_per_page 50
|
||||
|
||||
@impl true
|
||||
def mount(_params, %{"user_id" => user_id} = _session, socket)
|
||||
when not is_nil(user_id) and is_connected?(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(
|
||||
characters: AsyncResult.loading(),
|
||||
search_term: "",
|
||||
show_deleted: true,
|
||||
page: 1,
|
||||
per_page: @characters_per_page,
|
||||
sort_by: :name,
|
||||
sort_dir: :asc
|
||||
)
|
||||
|> load_characters_async()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(
|
||||
characters: AsyncResult.loading(),
|
||||
search_term: "",
|
||||
show_deleted: true,
|
||||
page: 1,
|
||||
per_page: @characters_per_page,
|
||||
sort_by: :name,
|
||||
sort_dir: :asc
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) when is_connected?(socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:active_page, :admin)
|
||||
|> assign(:page_title, "Admin - Characters")
|
||||
end
|
||||
|
||||
defp load_characters_async(socket) do
|
||||
socket
|
||||
|> assign_async(:characters, fn -> load_all_characters() end)
|
||||
end
|
||||
|
||||
defp load_all_characters do
|
||||
case WandererApp.Api.Character.admin_all() do
|
||||
{:ok, characters} ->
|
||||
{:ok, %{characters: characters}}
|
||||
|
||||
_ ->
|
||||
{:ok, %{characters: []}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"value" => term}, socket) do
|
||||
{:noreply, socket |> assign(:search_term, term) |> assign(:page, 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_deleted", _params, socket) do
|
||||
{:noreply,
|
||||
socket |> assign(:show_deleted, not socket.assigns.show_deleted) |> assign(:page, 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field}, socket) do
|
||||
field = String.to_existing_atom(field)
|
||||
|
||||
{sort_by, sort_dir} =
|
||||
if socket.assigns.sort_by == field do
|
||||
{field, toggle_dir(socket.assigns.sort_dir)}
|
||||
else
|
||||
{field, :asc}
|
||||
end
|
||||
|
||||
{:noreply, socket |> assign(sort_by: sort_by, sort_dir: sort_dir, page: 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("page", %{"page" => page}, socket) do
|
||||
{:noreply, socket |> assign(:page, String.to_integer(page))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(_event, _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def filter_characters(characters, search_term, show_deleted) do
|
||||
characters
|
||||
|> Enum.filter(fn char ->
|
||||
(show_deleted or not char.deleted) and
|
||||
(search_term == "" or
|
||||
String.contains?(String.downcase(char.name || ""), String.downcase(search_term)) or
|
||||
String.contains?(
|
||||
String.downcase(char.corporation_name || ""),
|
||||
String.downcase(search_term)
|
||||
) or
|
||||
String.contains?(
|
||||
String.downcase(char.alliance_name || ""),
|
||||
String.downcase(search_term)
|
||||
))
|
||||
end)
|
||||
end
|
||||
|
||||
def sort_characters(characters, sort_by, sort_dir) do
|
||||
Enum.sort_by(characters, &sort_value(&1, sort_by), sort_dir)
|
||||
end
|
||||
|
||||
defp sort_value(char, :name), do: String.downcase(char.name || "")
|
||||
defp sort_value(char, :corporation), do: String.downcase(char.corporation_name || "")
|
||||
defp sort_value(char, :alliance), do: String.downcase(char.alliance_name || "")
|
||||
defp sort_value(char, :user), do: String.downcase(user_name(char.user))
|
||||
defp sort_value(char, :registered), do: char.inserted_at || ~U[1970-01-01 00:00:00Z]
|
||||
|
||||
defp toggle_dir(:asc), do: :desc
|
||||
defp toggle_dir(:desc), do: :asc
|
||||
|
||||
def paginate(items, page, per_page) do
|
||||
items
|
||||
|> Enum.drop((page - 1) * per_page)
|
||||
|> Enum.take(per_page)
|
||||
end
|
||||
|
||||
def total_pages(items, per_page) do
|
||||
max(1, ceil(length(items) / per_page))
|
||||
end
|
||||
|
||||
def format_date(nil), do: "-"
|
||||
|
||||
def format_date(datetime) do
|
||||
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
|
||||
end
|
||||
|
||||
def user_name(nil), do: "Unlinked"
|
||||
def user_name(%{name: name}), do: name
|
||||
end
|
||||
166
lib/wanderer_app_web/live/admin/admin_characters_live.html.heex
Normal file
166
lib/wanderer_app_web/live/admin/admin_characters_live.html.heex
Normal file
@@ -0,0 +1,166 @@
|
||||
<main class="w-full h-full col-span-2 lg:col-span-1 p-4 pl-20 overflow-auto">
|
||||
<div class="page-content">
|
||||
<div class="container-fluid px-[0.625rem]">
|
||||
<!-- Header -->
|
||||
<div class="grid grid-cols-1 pb-6">
|
||||
<div class="md:flex items-center justify-between px-[2px]">
|
||||
<h4 class="text-[18px] font-medium text-gray-800 mb-sm-0 grow dark:text-gray-100 mb-2 md:mb-0">
|
||||
Admin - Characters
|
||||
</h4>
|
||||
<.link navigate={~p"/admin"} class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-arrow-left-solid" class="w-4 h-4" /> Back to Admin
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="card dark:bg-zinc-800 dark:border-zinc-600 mb-4">
|
||||
<div class="card-body flex flex-row gap-4 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, corporation, or alliance..."
|
||||
value={@search_term}
|
||||
phx-keyup="search"
|
||||
phx-debounce="300"
|
||||
name="search"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
checked={@show_deleted}
|
||||
phx-click="toggle_deleted"
|
||||
/>
|
||||
<span class="text-sm">Show deleted</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Characters Table -->
|
||||
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
|
||||
<div class="card-body">
|
||||
<.async_result :let={characters} assign={@characters}>
|
||||
<:loading>
|
||||
<div class="flex justify-center p-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</:loading>
|
||||
<:failed :let={reason}>
|
||||
<div class="alert alert-error">{inspect(reason)}</div>
|
||||
</:failed>
|
||||
|
||||
<% filtered = filter_characters(characters, @search_term, @show_deleted) %>
|
||||
<% sorted = sort_characters(filtered, @sort_by, @sort_dir) %>
|
||||
<% paginated = paginate(sorted, @page, @per_page) %>
|
||||
|
||||
<div class="overflow-x-auto !max-h-[60vh] !overflow-y-auto">
|
||||
<table class="table table-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:for={
|
||||
{label, field} <- [
|
||||
{"Character", :name},
|
||||
{"Corporation", :corporation},
|
||||
{"Alliance", :alliance},
|
||||
{"User Account", :user},
|
||||
{"Registered", :registered}
|
||||
]
|
||||
}
|
||||
phx-click="sort"
|
||||
phx-value-field={field}
|
||||
class="cursor-pointer select-none hover:bg-base-200"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
{label}
|
||||
<span :if={@sort_by == field}>
|
||||
<.icon :if={@sort_dir == :asc} name="hero-chevron-up" class="w-3 h-3" />
|
||||
<.icon
|
||||
:if={@sort_dir == :desc}
|
||||
name="hero-chevron-down"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-characters" phx-update="replace">
|
||||
<tr :for={char <- paginated} id={"char-#{char.id}"}>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<.avatar url={member_icon_url(char.eve_id)} label={char.name} />
|
||||
<span class={if char.deleted, do: "line-through text-gray-500", else: ""}>
|
||||
{char.name}
|
||||
</span>
|
||||
<span :if={char.deleted} class="badge badge-error badge-sm">
|
||||
Deleted
|
||||
</span>
|
||||
<span :if={char.online} class="badge badge-success badge-sm">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span :if={char.corporation_name}>
|
||||
{char.corporation_name}
|
||||
<span :if={char.corporation_ticker} class="text-gray-400">
|
||||
[{char.corporation_ticker}]
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :if={char.alliance_name}>
|
||||
{char.alliance_name}
|
||||
<span :if={char.alliance_ticker} class="text-gray-400">
|
||||
[{char.alliance_ticker}]
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{user_name(char.user)}</td>
|
||||
<td>
|
||||
<span class="text-sm">{format_date(char.inserted_at)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div :if={length(filtered) > @per_page} class="flex items-center justify-between mt-4">
|
||||
<span class="text-sm text-gray-400">
|
||||
Page {@page} of {total_pages(filtered, @per_page)} ({length(filtered)} characters)
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
phx-click="page"
|
||||
phx-value-page={max(1, @page - 1)}
|
||||
disabled={@page <= 1}
|
||||
class={"btn btn-sm btn-ghost " <> if(@page <= 1, do: "btn-disabled", else: "")}
|
||||
>
|
||||
<.icon name="hero-chevron-left" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="page"
|
||||
phx-value-page={min(total_pages(filtered, @per_page), @page + 1)}
|
||||
disabled={@page >= total_pages(filtered, @per_page)}
|
||||
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered, @per_page), do: "btn-disabled", else: "")}
|
||||
>
|
||||
<.icon name="hero-chevron-right" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div :if={length(filtered) == 0} class="text-center py-8 text-gray-400">
|
||||
No characters found
|
||||
</div>
|
||||
</.async_result>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -24,6 +24,18 @@
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
|
||||
<div class="card-body">
|
||||
<span class="text-gray-400 dark:text-gray-400">Characters</span>
|
||||
<.link
|
||||
class="btn mt-2 w-full btn-neutral rounded-none"
|
||||
navigate={~p"/admin/characters"}
|
||||
>
|
||||
<.icon name="hero-users-solid" class="w-6 h-6" />
|
||||
<h3 class="card-title text-center text-md">View All Characters</h3>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<div :if={@restrict_maps_creation?} class="card dark:bg-zinc-800 dark:border-zinc-600">
|
||||
<div class="card-body">
|
||||
<.button class="mt-2" type="button" phx-click="create-map">
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
is_topping_up?: false,
|
||||
is_withdrawing?: false,
|
||||
error: nil
|
||||
)}
|
||||
end
|
||||
@@ -61,12 +62,102 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
|
||||
{"ALL", nil}
|
||||
]
|
||||
)
|
||||
|> assign(is_topping_up?: true)}
|
||||
|> assign(is_topping_up?: true, is_withdrawing?: false)}
|
||||
|
||||
@impl true
|
||||
def handle_event("hide_topup", _, socket),
|
||||
do: {:noreply, socket |> assign(is_topping_up?: false)}
|
||||
|
||||
@impl true
|
||||
def handle_event("show_withdraw", _, socket),
|
||||
do:
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:withdraw_amounts,
|
||||
[
|
||||
{"50M", 50_000_000},
|
||||
{"100M", 100_000_000},
|
||||
{"250M", 250_000_000},
|
||||
{"500M", 500_000_000},
|
||||
{"1B", 1_000_000_000},
|
||||
{"2.5B", 2_500_000_000},
|
||||
{"5B", 5_000_000_000},
|
||||
{"10B", 10_000_000_000},
|
||||
{"ALL", nil}
|
||||
]
|
||||
)
|
||||
|> assign(is_withdrawing?: true, is_topping_up?: false)}
|
||||
|
||||
@impl true
|
||||
def handle_event("hide_withdraw", _, socket),
|
||||
do: {:noreply, socket |> assign(is_withdrawing?: false)}
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"withdraw",
|
||||
%{"amount" => amount} = _event,
|
||||
%{assigns: %{current_user: current_user, map: map, map_id: map_id}} = socket
|
||||
) do
|
||||
user =
|
||||
current_user.id
|
||||
|> WandererApp.User.load()
|
||||
|
||||
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
|
||||
|
||||
amount =
|
||||
if amount == "" do
|
||||
map_balance
|
||||
else
|
||||
amount |> Decimal.new() |> Decimal.to_float()
|
||||
end
|
||||
|
||||
case amount <= map_balance do
|
||||
true ->
|
||||
{:ok, _t} =
|
||||
WandererApp.Api.MapTransaction.create(%{
|
||||
map_id: map_id,
|
||||
user_id: current_user.id,
|
||||
amount: amount,
|
||||
type: :out
|
||||
})
|
||||
|
||||
{:ok, user_balance} =
|
||||
user
|
||||
|> WandererApp.User.get_balance()
|
||||
|
||||
{:ok, _user} =
|
||||
user
|
||||
|> WandererApp.Api.User.update_balance(%{
|
||||
balance: (user_balance || 0.0) + amount
|
||||
})
|
||||
|
||||
{:ok, user_balance} =
|
||||
current_user.id
|
||||
|> WandererApp.User.load()
|
||||
|> WandererApp.User.get_balance()
|
||||
|
||||
{:ok, map_balance} = WandererApp.Map.SubscriptionManager.get_balance(map)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
is_withdrawing?: false,
|
||||
map_balance: map_balance,
|
||||
user_balance: user_balance
|
||||
)}
|
||||
|
||||
_ ->
|
||||
notify_to(
|
||||
socket.assigns.notify_to,
|
||||
socket.assigns.event_name,
|
||||
{:flash, :error, "Not enough ISK in map balance!"}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"topup",
|
||||
@@ -142,7 +233,7 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<.button
|
||||
:if={not @is_topping_up?}
|
||||
:if={not @is_topping_up? and not @is_withdrawing?}
|
||||
class="mt-2"
|
||||
type="button"
|
||||
phx-click="show_topup"
|
||||
@@ -150,6 +241,15 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
|
||||
>
|
||||
Top Up
|
||||
</.button>
|
||||
<.button
|
||||
:if={not @is_topping_up? and not @is_withdrawing?}
|
||||
class="mt-2"
|
||||
type="button"
|
||||
phx-click="show_withdraw"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Withdraw
|
||||
</.button>
|
||||
</div>
|
||||
<div class="stat-title">Map balance</div>
|
||||
<div class="stat-value text-white">
|
||||
@@ -210,6 +310,32 @@ defmodule WandererAppWeb.Maps.MapBalanceComponent do
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
:if={@is_withdrawing?}
|
||||
for={@topup_form}
|
||||
class="mt-2"
|
||||
phx-submit="withdraw"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.input
|
||||
type="select"
|
||||
field={f[:amount]}
|
||||
class="select h-8 min-h-[10px] !pt-1 !pb-1 text-sm bg-neutral-900"
|
||||
label="Withdraw amount"
|
||||
placeholder="Select withdraw amount"
|
||||
options={@withdraw_amounts}
|
||||
/>
|
||||
<div class="modal-action">
|
||||
<.button class="mt-2" type="button" phx-click="hide_withdraw" phx-target={@myself}>
|
||||
Cancel
|
||||
</.button>
|
||||
<.button class="mt-2" type="submit">
|
||||
Withdraw
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@@ -506,6 +506,7 @@ defmodule WandererAppWeb.Router do
|
||||
live("/maps", AdminMapsLive, :index)
|
||||
live("/maps/:id/edit", AdminMapsLive, :edit)
|
||||
live("/maps/:id/acls", AdminMapsLive, :view_acls)
|
||||
live("/characters", AdminCharactersLive, :index)
|
||||
end
|
||||
|
||||
error_tracker_dashboard("/errors",
|
||||
|
||||
Reference in New Issue
Block a user