Compare commits

...

13 Commits

Author SHA1 Message Date
CI
3b9c2dd996 chore: [skip ci] 2025-12-25 18:20:20 +00:00
CI
8a0f9a58d0 chore: release version v1.91.1 2025-12-25 18:20:20 +00:00
Dmitry Popov
5fe8caac0d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-25 19:19:47 +01:00
Dmitry Popov
f18f567727 chore: fix blog link styles 2025-12-25 19:19:44 +01:00
CI
91acc49980 chore: [skip ci] 2025-12-24 15:09:40 +00:00
CI
ae3873a225 chore: release version v1.91.0 2025-12-24 15:09:40 +00:00
Dmitry Popov
b351c6cc26 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-24 16:09:06 +01:00
Dmitry Popov
698244d945 feat(admin): added maps administration view with basic info, search, restore/delete, acls view and edit options 2025-12-24 16:09:03 +01:00
CI
2c7dd9dc5b chore: [skip ci] 2025-12-19 12:33:26 +00:00
CI
36934cce0b chore: release version v1.90.13 2025-12-19 12:33:26 +00:00
Dmitry Popov
b7da7e4ecb Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-19 13:32:46 +01:00
Dmitry Popov
6471ea5590 fix(core): fixed welcome page 2025-12-19 13:32:44 +01:00
CI
b46bcac642 chore: [skip ci] 2025-12-19 09:38:36 +00:00
11 changed files with 605 additions and 15 deletions

View File

@@ -2,6 +2,29 @@
<!-- changelog -->
## [v1.91.1](https://github.com/wanderer-industries/wanderer/compare/v1.91.0...v1.91.1) (2025-12-25)
## [v1.91.0](https://github.com/wanderer-industries/wanderer/compare/v1.90.13...v1.91.0) (2025-12-24)
### Features:
* admin: added maps administration view with basic info, search, restore/delete, acls view and edit options
## [v1.90.13](https://github.com/wanderer-industries/wanderer/compare/v1.90.12...v1.90.13) (2025-12-19)
### Bug Fixes:
* core: fixed welcome page
## [v1.90.12](https://github.com/wanderer-industries/wanderer/compare/v1.90.11...v1.90.12) (2025-12-19)

View File

@@ -1001,3 +1001,27 @@ body > div:first-of-type {
.verticalTabsContainer .p-tabview-panel {
flex-grow: 1;
}
/* Blog post CTA links - only in main post content */
.post-content a {
display: inline-block;
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
color: white !important;
padding: 0.5rem 1.25rem;
border-radius: 0.5rem;
text-decoration: none !important;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.3);
}
.post-content a:hover {
background: linear-gradient(135deg, #db2777 0%, #7c3aed 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4);
}
.post-content a:active {
transform: translateY(0);
box-shadow: 0 2px 10px rgba(236, 72, 153, 0.3);
}

View File

@@ -67,6 +67,8 @@ defmodule WandererApp.Api.Map do
)
define(:duplicate, action: :duplicate)
define(:admin_all, action: :admin_all)
define(:restore, action: :restore)
end
calculations do
@@ -107,6 +109,12 @@ defmodule WandererApp.Api.Map do
prepare WandererApp.Api.Preparations.FilterMapsByRoles
end
read :admin_all do
# Admin-only action that bypasses FilterMapsByRoles
# Returns ALL maps including soft-deleted ones with owner and ACLs loaded
prepare build(load: [:owner, :acls])
end
create :new do
accept [
:name,
@@ -194,6 +202,14 @@ defmodule WandererApp.Api.Map do
change(set_attribute(:deleted, true))
end
update :restore do
# Admin-only action to restore a soft-deleted map
accept([])
require_atomic? false
change(set_attribute(:deleted, false))
end
update :update_api_key do
accept [:public_api_key]
require_atomic? false

View File

@@ -41,12 +41,15 @@
<div class="absolute rounded-m top-0 left-0 w-full h-full bg-gradient-to-b from-transparent to-black opacity-75 group-hover:opacity-25 transition-opacity duration-300">
</div>
<div class="absolute w-full bottom-2 p-4">
<% [first_part, second_part] = String.split(post.title, ":", parts: 2) %>
<% {first_part, second_part} = case String.split(post.title, ":", parts: 2) do
[first, second] -> {first, second}
[first] -> {first, nil}
end %>
<h3 class="!m-0 !text-s font-bold break-normal ccp-font whitespace-nowrap text-white">
{first_part}
</h3>
<p class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part || ""}
<p :if={second_part} class="!m-0 !text-s text-white text-ellipsis overflow-hidden whitespace-nowrap ccp-font">
{second_part}
</p>
</div>
</div>

View File

@@ -115,7 +115,9 @@
{@post.description}
</h4>
<!--Post Content-->
{raw(@post.body)}
<div class="post-content">
{raw(@post.body)}
</div>
</div>
</div>
<!--/container-->

View File

@@ -15,6 +15,15 @@
</div>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 2xl:grid-cols-4 pb-6">
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<span class="text-gray-400 dark:text-gray-400">Maps Management</span>
<.link class="btn mt-2 w-full btn-neutral rounded-none" navigate={~p"/admin/maps"}>
<.icon name="hero-map-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Manage All Maps</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">

View File

@@ -0,0 +1,273 @@
defmodule WandererAppWeb.AdminMapsLive do
@moduledoc """
Admin LiveView for managing all maps on the server.
Allows admins to view, edit, soft-delete, and restore maps regardless of ownership.
"""
use WandererAppWeb, :live_view
alias Phoenix.LiveView.AsyncResult
require Logger
@maps_per_page 20
@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(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)
|> load_maps_async()}
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(
maps: AsyncResult.loading(),
search_term: "",
show_deleted: true,
page: 1,
per_page: @maps_per_page
)}
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 - Maps")
|> assign(:selected_map, nil)
|> assign(:form, nil)
end
defp apply_action(socket, :edit, %{"id" => map_id}) do
case load_map_for_edit(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Edit Map")
|> assign(:selected_map, map)
|> assign(
:form,
map
|> AshPhoenix.Form.for_update(:update, forms: [auto?: true])
|> to_form()
)
|> load_owner_options()
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
defp apply_action(socket, :view_acls, %{"id" => map_id}) do
case load_map_with_acls(map_id) do
{:ok, map} ->
socket
|> assign(:active_page, :admin)
|> assign(:page_title, "Admin - Map ACLs")
|> assign(:selected_map, map)
{:error, _} ->
socket
|> put_flash(:error, "Map not found")
|> push_navigate(to: ~p"/admin/maps")
end
end
# Data loading functions
defp load_maps_async(socket) do
socket
|> assign_async(:maps, fn -> load_all_maps() end)
end
defp load_all_maps do
case WandererApp.Api.Map.admin_all() do
{:ok, maps} ->
maps =
maps
|> Enum.sort_by(& &1.name, :asc)
{:ok, %{maps: maps}}
_ ->
{:ok, %{maps: []}}
end
end
defp load_map_for_edit(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, [:owner, :acls])
{:ok, map}
error ->
error
end
end
defp load_map_with_acls(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
{:ok, map} = Ash.load(map, acls: [:owner, :members])
{:ok, map}
error ->
error
end
end
defp load_owner_options(socket) do
case WandererApp.Api.Character.read() do
{:ok, characters} ->
options =
characters
|> Enum.map(fn c -> {c.name, c.id} end)
|> Enum.sort_by(&elem(&1, 0))
socket |> assign(:owner_options, options)
_ ->
socket |> assign(:owner_options, [])
end
end
# Event handlers
@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("delete_map", %{"id" => map_id}, socket) do
case soft_delete_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map marked as deleted")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to delete map")}
end
end
@impl true
def handle_event("restore_map", %{"id" => map_id}, socket) do
case restore_map(map_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Map restored successfully")
|> load_maps_async()}
{:error, _} ->
{:noreply, socket |> put_flash(:error, "Failed to restore map")}
end
end
@impl true
def handle_event("validate", %{"form" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.form, params)
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("save", %{"form" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: params) do
{:ok, _map} ->
{:noreply,
socket
|> put_flash(:info, "Map updated successfully")
|> push_navigate(to: ~p"/admin/maps")}
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
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
# Helper functions
defp soft_delete_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.mark_as_deleted(map)
error ->
error
end
end
defp restore_map(map_id) do
case WandererApp.Api.Map.by_id(map_id) do
{:ok, map} ->
WandererApp.Api.Map.restore(map)
error ->
error
end
end
def filter_maps(maps, search_term, show_deleted) do
maps
|> Enum.filter(fn map ->
(show_deleted or not map.deleted) and
(search_term == "" or
String.contains?(String.downcase(map.name || ""), String.downcase(search_term)) or
String.contains?(String.downcase(map.slug || ""), String.downcase(search_term)))
end)
end
def paginate(maps, page, per_page) do
maps
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
def total_pages(maps, per_page) do
max(1, ceil(length(maps) / per_page))
end
def format_date(nil), do: "-"
def format_date(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
def owner_name(nil), do: "No owner"
def owner_name(%{name: name}), do: name
end

View File

@@ -0,0 +1,240 @@
<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 - Maps Management
</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 or slug..."
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>
<!-- Maps Table -->
<div class="card dark:bg-zinc-800 dark:border-zinc-600">
<div class="card-body">
<.async_result :let={maps} assign={@maps}>
<: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_maps = filter_maps(maps, @search_term, @show_deleted) %>
<% paginated_maps = paginate(filtered_maps, @page, @per_page) %>
<.table id="admin-maps" rows={paginated_maps} class="!max-h-[60vh] !overflow-y-auto">
<:col :let={map} label="Name">
<div class="flex items-center gap-2">
<span class={if map.deleted, do: "line-through text-gray-500", else: ""}>
{map.name}
</span>
<span :if={map.deleted} class="badge badge-error badge-sm">Deleted</span>
</div>
</:col>
<:col :let={map} label="Slug">
<span class="text-sm text-gray-400">{map.slug}</span>
</:col>
<:col :let={map} label="Owner">
{owner_name(map.owner)}
</:col>
<:col :let={map} label="Created">
<span class="text-sm">{format_date(map.inserted_at)}</span>
</:col>
<:col :let={map} label="Scope">
<span class="badge badge-ghost badge-sm">{map.scope}</span>
</:col>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/edit"}
class="btn btn-ghost btn-xs hover:text-white"
title="Edit"
>
<.icon name="hero-pencil-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<.link
patch={~p"/admin/maps/#{map.id}/acls"}
class="btn btn-ghost btn-xs hover:text-white"
title="View ACLs"
>
<.icon name="hero-shield-check-solid" class="w-4 h-4" />
</.link>
</:action>
<:action :let={map}>
<button
:if={not map.deleted}
phx-click="delete_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to delete this map?"]}
class="btn btn-ghost btn-xs hover:text-red-500"
title="Delete"
>
<.icon name="hero-trash-solid" class="w-4 h-4" />
</button>
<button
:if={map.deleted}
phx-click="restore_map"
phx-value-id={map.id}
data={[confirm: "Are you sure you want to restore this map?"]}
class="btn btn-ghost btn-xs hover:text-green-500"
title="Restore"
>
<.icon name="hero-arrow-path-solid" class="w-4 h-4" />
</button>
</:action>
</.table>
<!-- Pagination -->
<div
:if={length(filtered_maps) > @per_page}
class="flex items-center justify-between mt-4"
>
<span class="text-sm text-gray-400">
Page {@page} of {total_pages(filtered_maps, @per_page)} ({length(filtered_maps)} maps)
</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_maps, @per_page), @page + 1)}
disabled={@page >= total_pages(filtered_maps, @per_page)}
class={"btn btn-sm btn-ghost " <> if(@page >= total_pages(filtered_maps, @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_maps) == 0} class="text-center py-8 text-gray-400">
No maps found
</div>
</.async_result>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<.modal
:if={@live_action == :edit and not is_nil(@selected_map)}
title="Edit Map"
class="!w-[500px]"
id="edit_map_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
<.input type="text" field={f[:name]} label="Name" placeholder="Map name" />
<.input type="text" field={f[:slug]} label="Slug" placeholder="map-slug" />
<.input
type="textarea"
field={f[:description]}
label="Description"
placeholder="Description"
/>
<.input
type="select"
field={f[:scope]}
label="Scope"
options={[
{"Wormholes", :wormholes},
{"Stargates", :stargates},
{"None", :none},
{"All", :all}
]}
/>
<.input
type="select"
field={f[:owner_id]}
label="Owner"
options={@owner_options}
prompt="Select owner..."
/>
<div class="modal-action">
<.button type="submit" phx-disable-with="Saving...">
Save Changes
</.button>
</div>
</.form>
</.modal>
<!-- View ACLs Modal -->
<.modal
:if={@live_action == :view_acls and not is_nil(@selected_map)}
title={"ACLs for: #{@selected_map.name}"}
class="!w-[600px]"
id="view_acls_modal"
show
on_cancel={JS.patch(~p"/admin/maps")}
>
<div class="space-y-4">
<div :if={Enum.empty?(@selected_map.acls)} class="text-gray-400 text-center py-4">
No ACLs assigned to this map
</div>
<div :for={acl <- @selected_map.acls} class="card bg-base-200">
<div class="card-body p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-bold">{acl.name}</h3>
<p class="text-sm text-gray-400">{acl.description || "No description"}</p>
</div>
<div class="badge badge-ghost">
{length(acl.members)} members
</div>
</div>
<div class="text-sm mt-2">
<span class="text-gray-400">Owner:</span>
<span>{if acl.owner, do: acl.owner.name, else: "Unknown"}</span>
</div>
</div>
</div>
</div>
<div class="modal-action">
<.link patch={~p"/admin/maps"} class="btn btn-ghost">
Close
</.link>
</div>
</.modal>
</main>

View File

@@ -503,6 +503,9 @@ defmodule WandererAppWeb.Router do
] do
live("/", AdminLive, :index)
live("/invite", AdminLive, :add_invite_link)
live("/maps", AdminMapsLive, :index)
live("/maps/:id/edit", AdminMapsLive, :edit)
live("/maps/:id/acls", AdminMapsLive, :view_acls)
end
error_tracker_dashboard("/errors",

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.90.12"
@version "1.91.1"
def project do
[

View File

@@ -1,5 +1,5 @@
%{
title: "Christmas Giveaway Challenge",
title: "Event: Christmas Giveaway Challenge",
author: "Wanderer Team",
cover_image_uri: "/images/news/2025/12-18-advent-giveaway/cover.jpg",
tags: ~w(event giveaway challenge christmas advent partnership),
@@ -11,12 +11,16 @@ description: "Join our Advent Christmas Giveaway Challenge! Win exclusive partne
![Christmas Giveaway Challenge](/images/news/2025/12-18-advent-giveaway/cover.jpg "Christmas Giveaway Challenge")
### Event Details
- **Event Name:** Advent Christmas Giveaway
- **Duration:** 1 week (7 days, 7 codes)
- **Event Link:** [Advent Christmas Giveaway](https://eventcortex.com/events/invite/cYdBywu1ygfVS3UN6ZZcmDzL1q85aDmH)
### The Season of Giving
This holiday season, we're spreading some festive cheer with a special event for our community: the **Advent Christmas Giveaway Challenge**!
Starting next week, we'll be giving away **1 exclusive partnership code every day for 7 days**. But here's the twist — it's a challenge!
---
### How It Works
@@ -34,14 +38,7 @@ Starting next week, we'll be giving away **1 exclusive partnership code every da
- Each day features a single partnership code.
- Miss today? Come back tomorrow for another chance!
---
### Event Details
- **Event Name:** Advent Christmas Giveaway
- **Duration:** 1 week (7 days, 7 codes)
- **Organizer:** @Demiro (Wanderer core developer, EventCortex CTO)
- **Event Link:** [Advent Christmas Giveaway - EventCortex](https://eventcortex.com/events/invite/cYdBywu1ygfVS3UN6ZZcmDzL1q85aDmH)
---