baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
// <copyright file="ServerController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.API
{
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameServer;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Server API controller.
/// </summary>
[Route("api/")]
public class ServerController : Controller
{
private IDictionary<int, IGameServer> _gameServers;
/// <summary>
/// Initializes a new instance of the <see cref="ServerController"/> class.
/// </summary>
/// <param name="gameServers">The game servers.</param>
public ServerController(IDictionary<int, IGameServer> gameServers) => this._gameServers = gameServers;
/// <summary>
/// Sends a global message to the specified server.
/// </summary>
/// <param name="id">The server id.</param>
/// <param name="msg">The message.</param>
[Route("send/{id=0}")]
public async Task<IActionResult> SendGlobalMessage(int id, [FromQuery(Name = "msg")] string msg)
{
var server = (GameServer)this._gameServers.Values.ElementAt(id);
if (server is not null)
{
await server.Context.SendGlobalNotificationAsync(msg).ConfigureAwait(false);
return this.Ok("Done");
}
return this.Ok("Server not ready");
}
/// <summary>
/// Gets a flag, if the specified account is currently online.
/// </summary>
/// <param name="accountName">Name of the account.</param>
/// <returns>True, when online.</returns>
[HttpGet]
[Route("is-online/{accountName=0}")]
public async Task<bool> GetIsOnlineAsync(string accountName)
{
var isOnline = false;
foreach (var server in this._gameServers.Values.OfType<GameServer>())
{
var players = await server.Context.GetPlayersAsync().ConfigureAwait(false);
if (players.Any(p => p.Account?.LoginName == accountName))
{
isOnline = true;
break;
}
}
return isOnline;
}
/// <summary>
/// Gets the server state.
/// </summary>
[HttpGet]
[Route("status")]
public IActionResult ServerState()
{
int sum = 0;
var list = new List<string>();
this._gameServers.Values.ForEach(async item =>
{
var server = item as GameServer;
if (server is not null)
{
await server.Context.ForEachPlayerAsync(player =>
{
list.Add(player.GetName());
return Task.CompletedTask;
}).ConfigureAwait(false);
sum = sum + server.Context.PlayerCount;
}
});
var item = new
{
state = "Online",
players = sum,
playersList = list,
};
return this.Ok(JsonSerializer.Serialize(item));
}
}
}

View File

@@ -0,0 +1,16 @@
// <copyright file="AdminPanelEnvironment.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel;
/// <summary>
/// Environment information about the admin panel.
/// </summary>
public static class AdminPanelEnvironment
{
/// <summary>
/// Gets a value indicating whether the hosting of the admin panel is embedded into an all-in-one deployment.
/// </summary>
public static bool IsHostingEmbedded { get; internal set; }
}

View File

@@ -0,0 +1,67 @@
@using MUnique.OpenMU.Web.AdminPanel.Components.Layout
@using MUnique.OpenMU.Web.Shared.Services
@code {
[CascadingParameter]
public HttpContext? HttpContext { get; set; }
private string CurrentTheme =>
ThemeController.NormalizeTheme(this.HttpContext?.Request.Cookies[ThemeController.CookieName]);
}
<!DOCTYPE html>
<html lang="en" data-bs-theme="@this.CurrentTheme">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenMU AdminPanel</title>
<base href="/" />
<ResourcePreloader />
<link href="@Assets["MUnique.OpenMU.Startup.styles.css"]" rel="stylesheet" />
<link href="@Assets["_content/MUnique.OpenMU.Web.AdminPanel/MUnique.OpenMU.Web.AdminPanel.styles.css"]" rel="stylesheet" />
@foreach (var stylesheetSrc in Web.AdminPanel.Exports.Stylesheets)
{
<link href="@Assets[stylesheetSrc]" rel="stylesheet" />
}
<ImportMap />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
@foreach (var scriptSrc in Web.AdminPanel.Exports.Scripts)
{
<script src="@Assets[scriptSrc]"></script>
}
@if (Web.AdminPanel.Exports.ScriptMappings.Any())
{
var sb = new StringBuilder();
sb.AppendLine("System.config({").AppendLine(" map: {");
bool isFirst = true;
foreach (var scriptMapping in Web.AdminPanel.Exports.ScriptMappings)
{
if (!isFirst)
{
sb.AppendLine(",");
}
isFirst = false;
sb.Append($"'{scriptMapping.Key}': '")
.Append(scriptMapping.Path)
.Append("'");
}
sb.AppendLine(" }")
.AppendLine("});");
<script>
// To be able to resolve three etc. in our TS files and the resulting javascript, SystemJS needs to be configured.
// These modules are loaded by SystemJS when they get requested the first time. No need to manually load them.
@((MarkupString)sb.ToString())
</script>
}
</body>
</html>

View File

@@ -0,0 +1,80 @@
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.DataModel.Configuration
@if (this.Configuration is null)
{
<span>@Resources.Loading</span>
}
else
{
<EditForm Model="@Configuration" OnValidSubmit="OnValidSubmit">
<DataAnnotationsValidator/>
<TextField Id="descriptionInput" Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.Description)) @bind-Value="@Configuration.Description" />
<NumberField Id="clientListenerPortInput" Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.ClientListenerPort)) @bind-Value="@Configuration.ClientListenerPort" />
<LookupField TObject=GameClientDefinition Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.Client)) @bind-Value="@Configuration.GameClientDefinition"/>
<div>
<label htmlFor="patchVersionInput">@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.CurrentPatchVersion)))</label>
<div id="patchVersionInput">
<div>
<span id="major-addon">@Resources.MajorVersion</span>
</div>
<InputNumber id="patchVersionMajorInput" @bind-Value=@this.Configuration.CurrentVersionMajor aria-describedby="major-addon"/>
<ValidationMessage For="@(() => this.Configuration.CurrentVersionMajor)"/>
</div>
<div>
<div>
<span id="minor-addon">@Resources.MinorVersion</span>
</div>
<InputNumber id="patchVersionMinorInput" @bind-Value=@this.Configuration.CurrentVersionMinor aria-describedby="minor-addon"/>
<ValidationMessage For="@(() => this.Configuration.CurrentVersionMinor)"/>
</div>
<div>
<div>
<span id="patch-addon">@Resources.Patch</span>
</div>
<InputNumber id="patchVersionPatchInput" @bind-Value=@this.Configuration.CurrentVersionPatch aria-describedby="patch-addon"/>
<ValidationMessage For="@(() => this.Configuration.CurrentVersionPatch)"/>
</div>
</div>
<div>
<label htmlFor="patchAddressInput">@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.PatchAddress)))</label>
<div id="patchAddressInput">
<div>
<span id="ftp-addon">ftp://</span>
</div>
<InputText @bind-Value=@this.Configuration.PatchAddress aria-describedby="ftp-addon"/>
</div>
</div>
<BooleanField Id="disconnectOnUnknownPacketInput" Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.DisconnectOnUnknownPacket)) @bind-Value="@Configuration.DisconnectOnUnknownPacket"/>
<NumberField Id="maximumReceiveSizeInput" Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaximumReceiveSize)) @bind-Value="@Configuration.MaximumReceiveSize"/>
<div>
<label htmlFor="maxConnectionsPerAddressInput">@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaxConnectionsPerAddress)))</label>
<div id="maxConnectionsPerAddressInput">
<div>
<span id="connections-addon">
<InputCheckbox class="form-check-input" id="checkMaxConnectionsPerAddressInput" @bind-Value=@this.Configuration.CheckMaxConnectionsPerAddress/>
</span>
</div>
<InputNumber min="1" @bind-Value=@this.Configuration.MaxConnectionsPerAddress aria-describedby="connections-addon"/>
<ValidationMessage For="@(() => this.Configuration.MaxConnectionsPerAddress)"/>
</div>
</div>
<NumberField Id="maxConnectionsInput" Label=@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaxConnections)) @bind-Value="@Configuration.MaxConnections"/>
<NumberField Id="listenerBacklogInput" Label="@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.ListenerBacklog))" @bind-Value="@Configuration.ListenerBacklog"/>
<NumberField Id="maxFtpRequestsInput" Label="@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaxFtpRequests))" @bind-Value="@Configuration.MaxFtpRequests" />
<NumberField Id="maxIpRequestsInput" Label="@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaxIpRequests))" @bind-Value="@Configuration.MaxIpRequests" />
<NumberField Id="maxServerListRequestsInput" Label="@typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.MaxServerListRequests))" @bind-Value="@Configuration.MaxServerListRequests" />
<NumberField Id="timeoutSecondsInput" Label=@($"{typeof(ConnectServerDefinition).GetPropertyCaption(nameof(ConnectServerDefinition.Timeout))} ({Resources.Seconds})") @bind-Value="@Configuration.TimeoutSeconds"/>
<ValidationSummary/>
<div>
<button type="submit" class="btn btn-primary">@Resources.Save</button>
@if (this.OnCancel != null)
{
<button type="button" onClick=@this.OnCancel>@Resources.Cancel</button>
}
</div>
</EditForm>
}

View File

@@ -0,0 +1,44 @@
// <copyright file="ConnectServerConfiguration.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components.ConnectServer;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// Edit component for the <see cref="ConnectServerDefinition"/>.
/// </summary>
/// <seealso cref="Microsoft.AspNetCore.Components.ComponentBase" />
public partial class ConnectServerConfiguration
{
/// <summary>
/// Gets or sets the <see cref="EditForm.OnValidSubmit"/> event callback.
/// </summary>
[Parameter]
public EventCallback OnValidSubmit { get; set; }
/// <summary>
/// Gets or sets the task which should be executed when the cancel button gets clicked. If null, no cancel button is shown.
/// </summary>
[Parameter]
public Task? OnCancel { get; set; }
/// <summary>
/// Gets or sets the model.
/// </summary>
[Parameter]
public ConnectServerDefinition Model { get; set; } = null!;
private ConnectServerConfigurationViewItem? Configuration { get; set; }
/// <inheritdoc/>
protected override void OnInitialized()
{
base.OnInitialized();
this.Configuration = new ConnectServerConfigurationViewItem(this.Model);
}
}

View File

@@ -0,0 +1,210 @@
// <copyright file="ConnectServerConfigurationViewItem.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components.ConnectServer;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence;
/// <summary>
/// A view item for the <see cref="ConnectServerDefinition"/>.
/// </summary>
public class ConnectServerConfigurationViewItem
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectServerConfigurationViewItem"/> class.
/// </summary>
/// <param name="config">The configuration.</param>
public ConnectServerConfigurationViewItem(ConnectServerDefinition config)
{
this.Configuration = config;
}
/// <summary>
/// Gets the internal id of the configuration.
/// </summary>
[Browsable(false)]
public Guid Id => this.Configuration.GetId();
/// <summary>
/// Gets the underlying configuration object.
/// </summary>
[Browsable(false)]
public ConnectServerDefinition Configuration { get; }
/// <summary>
/// Gets or sets the description of the server.
/// </summary>
/// <remarks>
/// Will be displayed in the server list in the admin panel as <see cref="P:MUnique.OpenMU.Interfaces.IManageableServer.Description" />.
/// </remarks>
[Required]
public string Description
{
get => this.Configuration.Description;
set => this.Configuration.Description = value;
}
/// <summary>
/// Gets or sets the game client definition.
/// </summary>
[Required]
public GameClientDefinition? GameClientDefinition
{
get => this.Configuration.Client;
set => this.Configuration.Client = value;
}
/// <summary>
/// Gets or sets a value indicating whether the client should get disconnected when a unknown packet is getting received.
/// </summary>
public bool DisconnectOnUnknownPacket
{
get => this.Configuration.DisconnectOnUnknownPacket;
set => this.Configuration.DisconnectOnUnknownPacket = value;
}
/// <summary>
/// Gets or sets the maximum size of the packets which should be received from the client. If this size is exceeded, the client will be disconnected.
/// </summary>
/// <remarks>
/// DOS protection.
/// </remarks>
[Range(3, 255)]
public int MaximumReceiveSize
{
get => this.Configuration.MaximumReceiveSize;
set => this.Configuration.MaximumReceiveSize = (byte)value;
}
/// <summary>
/// Gets or sets the network port on which the server is listening.
/// </summary>
[Range(1, 65535)]
public int ClientListenerPort
{
get => this.Configuration.ClientListenerPort;
set => this.Configuration.ClientListenerPort = (ushort)value;
}
/// <summary>
/// Gets or sets the patch address.
/// </summary>
public string PatchAddress
{
get => this.Configuration.PatchAddress;
set => this.Configuration.PatchAddress = value;
}
/// <summary>
/// Gets or sets the maximum connections per ip.
/// </summary>
[Range(1, int.MaxValue)]
public int MaxConnectionsPerAddress
{
get => this.Configuration.MaxConnectionsPerAddress;
set => this.Configuration.MaxConnectionsPerAddress = value;
}
/// <summary>
/// Gets or sets a value indicating whether the <see cref="P:MUnique.OpenMU.DataModel.Configuration.ConnectServerDefinition.MaxConnectionsPerAddress" /> should be checked.
/// </summary>
public bool CheckMaxConnectionsPerAddress
{
get => this.Configuration.CheckMaxConnectionsPerAddress;
set => this.Configuration.CheckMaxConnectionsPerAddress = value;
}
/// <summary>
/// Gets or sets the maximum connections the connect server should handle.
/// </summary>
[Range(1, int.MaxValue)]
public int MaxConnections
{
get => this.Configuration.MaxConnections;
set => this.Configuration.MaxConnections = value;
}
/// <summary>
/// Gets or sets the listener backlog for the client listener.
/// </summary>
[Range(1, int.MaxValue)]
public int ListenerBacklog
{
get => this.Configuration.ListenerBacklog;
set => this.Configuration.ListenerBacklog = value;
}
/// <summary>
/// Gets or sets the maximum FTP requests per connection.
/// </summary>
[Range(1, int.MaxValue)]
public int MaxFtpRequests
{
get => this.Configuration.MaxFtpRequests;
set => this.Configuration.MaxFtpRequests = value;
}
/// <summary>
/// Gets or sets the maximum ip requests per connection.
/// </summary>
[Range(1, int.MaxValue)]
public int MaxIpRequests
{
get => this.Configuration.MaxIpRequests;
set => this.Configuration.MaxIpRequests = value;
}
/// <summary>
/// Gets or sets the maximum server list requests per connection.
/// </summary>
[Range(1, int.MaxValue)]
public int MaxServerListRequests
{
get => this.Configuration.MaxServerListRequests;
set => this.Configuration.MaxServerListRequests = value;
}
/// <summary>
/// Gets or sets the timeout in seconds.
/// </summary>
[Range(10, 3600)]
public int TimeoutSeconds
{
get => (int)this.Configuration.Timeout.TotalSeconds;
set => this.Configuration.Timeout = new TimeSpan(value * TimeSpan.TicksPerSecond);
}
/// <summary>
/// Gets or sets the current version major.
/// </summary>
[Range(0, 255)]
public int CurrentVersionMajor
{
get => this.Configuration.CurrentPatchVersion?[0] ?? 0;
set => (this.Configuration.CurrentPatchVersion ??= new byte[3])[0] = (byte)value;
}
/// <summary>
/// Gets or sets the current version minor.
/// </summary>
[Range(0, 255)]
public int CurrentVersionMinor
{
get => this.Configuration.CurrentPatchVersion?[1] ?? 0;
set => (this.Configuration.CurrentPatchVersion ??= new byte[3])[1] = (byte)value;
}
/// <summary>
/// Gets or sets the current version patch.
/// </summary>
[Range(0, 255)]
public int CurrentVersionPatch
{
get => this.Configuration.CurrentPatchVersion?[2] ?? 0;
set => (this.Configuration.CurrentPatchVersion ??= new byte[3])[2] = (byte)value;
}
}

View File

@@ -0,0 +1,72 @@
@using MUnique.OpenMU.Web.AdminPanel.Properties
<div>
@if (this.SelectedVersion is null)
{
<span>@Resources.Loading</span>
}
else if (this.IsInstalling)
{
<span>@Resources.InstallingPleaseWait</span>
}
else if (this.IsInstalled)
{
<p>@Resources.FinishedHaveFun</p>
if (!AdminPanelEnvironment.IsHostingEmbedded)
{
<p>@Resources.PleaseRestartTheConnectAndGameServerContainers</p>
}
<button class="btn btn-primary" @onclick="async () => await this.InstallationFinished.InvokeAsync()">@Resources.OK</button>
}
else
{
if (this.CurrentConnections > 0)
{
<div class="alert alert-danger" role="alert">
@Resources.FirstCloseAllConnectionsToTheServer
</div>
}
<p>
<h2>@Resources.SelectTheGameVersion</h2>
<div>
@foreach (var initializer in this.SetupService.Versions)
{
<div class="form-check">
<input class="form-check-input" type="radio" name="version" id="@initializer.Key" @onclick="() => this.OnSelectVersion(initializer.Key)">
<label class="form-check-label" for="@initializer.Key">@initializer.Caption</label>
</div>
}
</div>
</p>
<p>
<h2>@Resources.HowManyGameServersQuestion</h2>
<div>
<label for="gameServerCount" class="form-label">@Resources.GameServerCount (@this.GameServerCount)</label>
<input type="range" class="form-range" min="1" max="10" value="@this.GameServerCount" id="gameServerCount" @oninput="this.OnGameServerCountChange">
</div>
</p>
<p>
<h2>@Resources.TestAccountsQuestion</h2>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="testAccountsCheck" @oninput="this.OnTestAccountsChange">
<label class="form-check-label" for="testAccountsCheck">
@Resources.YesCreateTestAccounts
</label>
</div>
</p>
<p>
@if (this.CurrentConnections == 0)
{
<button class="btn btn-primary" @onclick="this.StartInstallationAsync">@Resources.StartInstall</button>
}
else
{
<button class="btn btn-primary disabled">@Resources.StartInstall</button>
}
</p>
}
</div>

View File

@@ -0,0 +1,108 @@
// <copyright file="Install.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.Initialization;
using MUnique.OpenMU.Web.AdminPanel.Services;
/// <summary>
/// The component which allows to initialize the database.
/// </summary>
public sealed partial class Install
{
/// <summary>
/// Gets or sets the selected version.
/// </summary>
public IDataInitializationPlugIn? SelectedVersion { get; set; }
/// <summary>
/// Gets or sets the game server count.
/// </summary>
public int GameServerCount { get; set; } = 2;
/// <summary>
/// Gets or sets a value indicating whether to create test accounts.
/// </summary>
public bool CreateTestAccounts { get; set; }
/// <summary>
/// Gets a value indicating whether this instance is installing.
/// </summary>
public bool IsInstalling { get; private set; }
/// <summary>
/// Gets a value indicating whether this instance has installed.
/// </summary>
public bool IsInstalled { get; private set; }
/// <summary>
/// Gets or sets the installation finished callback.
/// </summary>
[Parameter]
public EventCallback InstallationFinished { get; set; }
/// <summary>
/// Gets or sets the setup service.
/// </summary>
[Inject]
public SetupService SetupService { get; set; } = null!;
/// <summary>
/// Gets or sets the server provider.
/// </summary>
[Inject]
public IServerProvider ServerProvider { get; set; } = null!;
private int CurrentConnections => this.ServerProvider.Servers.Where(s => s.ServerState != ServerState.Timeout).Sum(s => s.CurrentConnections);
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
this.SelectedVersion = this.SetupService.Versions.First();
}
/// <summary>
/// Starts the installation.
/// </summary>
private async Task StartInstallationAsync()
{
this.IsInstalling = true;
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
try
{
await this.SetupService.CreateDatabaseAsync(() => this.SelectedVersion!.CreateInitialDataAsync((byte)this.GameServerCount, this.CreateTestAccounts)).ConfigureAwait(false);
}
finally
{
this.IsInstalled = true;
this.IsInstalling = false;
}
}
private void OnSelectVersion(string key)
{
this.SelectedVersion = this.SetupService.Versions.First(v => v.Key == key);
}
private void OnGameServerCountChange(ChangeEventArgs obj)
{
if (obj.Value is string strValue
&& int.TryParse(strValue, out var count))
{
this.GameServerCount = count;
}
}
private void OnTestAccountsChange(ChangeEventArgs obj)
{
if (obj.Value is bool value)
{
this.CreateTestAccounts = value;
}
}
}

View File

@@ -0,0 +1,36 @@
@using MUnique.OpenMU.DataModel.Configuration
@using MUnique.OpenMU.DataModel.Configuration.Items
@using MUnique.OpenMU.Web.AdminPanel.Properties
<li class="nav-item px-3 dropdown@(this._expandGameConfig ? " expanded" : "")">
<a class="nav-link dropdown-toggle" href="" role="button" aria-haspopup="true" aria-expanded="@(this._expandGameConfig)" @onclick="ToggleGameConfig" @onclick:preventDefault="true">
<span class="oi oi-cog" aria-hidden="true"></span> @Resources.GameConfiguration
</a>
<div class="dropdown-menu@(this._expandGameConfig ? " show" : "")">
<NavLink class="dropdown-item" href="@($"edit-config/{typeof(SystemConfiguration).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.System</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(GameClientDefinition).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.GameClients</NavLink>
<hr class="dropdown-divider"/>
<NavLink class="dropdown-item" href="@($"edit-config/{typeof(GameConfiguration).FullName}/{this.GameConfigurationId}/hide-collections")" @onclick="() => NavigationHistory.Clear()">@Resources.General</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(MonsterDefinition).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.Monsters</NavLink>
<NavLink class="dropdown-item" href="@($"merchants/")" @onclick="() => NavigationHistory.Clear()">@Resources.MerchantStores</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(CharacterClass).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.CharacterClasses</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(Skill).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.Skills</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(ItemDefinition).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.Items</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(DropItemGroup).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.DropItemGroups</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(GameMapDefinition).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.GameMaps</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(MiniGameDefinition).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.MiniGames</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(WarpInfo).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.WarpList</NavLink>
<NavLink class="dropdown-item" href="@($"edit-config-grid/{typeof(JewelMix).FullName}/")" @onclick="() => NavigationHistory.Clear()">@Resources.JewelMixes</NavLink>
<hr class="dropdown-divider"/>
<NavLink class="dropdown-item" href="plugins" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-puzzle-piece" aria-hidden="true"></span> @Resources.Plugins
</NavLink>
<NavLink class="dropdown-item" href="map-editor" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-globe" aria-hidden="true"></span> @Resources.MapEditor
</NavLink>
<hr class="dropdown-divider"/>
<NavLink class="dropdown-item" href="@($"edit-config/{typeof(GameConfiguration).FullName}/")" Match="NavLinkMatch.All" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-cog" aria-hidden="true"></span> @Resources.FullConfiguration
</NavLink>
</div>
</li>

View File

@@ -0,0 +1,32 @@
// <copyright file="ConfigNavMenu.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Navigation menu of the admin panel.
/// </summary>
public partial class ConfigNavMenu
{
private bool _expandGameConfig;
/// <summary>
/// Gets or sets the game configuration identifier.
/// </summary>
[Parameter]
[Required]
public Guid GameConfigurationId { get; set; }
[Inject]
private NavigationHistory NavigationHistory { get; set; } = null!;
private void ToggleGameConfig()
{
this._expandGameConfig = !this._expandGameConfig;
}
}

View File

@@ -0,0 +1,41 @@
@using MUnique.OpenMU.Web.AdminPanel.Properties
<div class="configuration-search">
<div class="input-group configuration-search-container">
<span class="input-group-text">
<span class="oi oi-magnifying-glass" aria-hidden="true"></span>
</span>
<input type="search"
class="form-control configuration-search__input"
placeholder="@($"{Resources.Search}...")"
@bind-value="this._searchText"
@bind-value:event="oninput"
@bind-value:after="this.OnSearchInputAsync"
title="@Resources.Search"
@onfocus="this.OnSearchFocus"
@onblur="this.OnSearchBlurAsync"
@onkeydown="this.OnSearchKeyDownAsync"
autocomplete="off" />
@if (this._isLoading)
{
<span class="input-group-text">
<div class="spinner-border spinner-border-sm text-muted" role="status"></div>
</span>
}
</div>
@if (this._searchResults.Count > 0 && !string.IsNullOrWhiteSpace(this._searchText))
{
<div class="dropdown-menu configuration-search__results show">
@foreach (var result in this._searchResults)
{
<button type="button"
class="dropdown-item configuration-search__result"
@onmousedown="@(() => this.NavigateToResult(result))"
@onmousedown:preventDefault="true">
<span class="configuration-search__result-caption">@result.Caption</span>
<span class="configuration-search__result-path">@result.Path</span>
</button>
}
</div>
}
</div>

View File

@@ -0,0 +1,220 @@
// <copyright file="ConfigurationSearch.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Web.AdminPanel.Services;
using MUnique.OpenMU.Web.Shared.Components;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Header search for configuration properties.
/// </summary>
public partial class ConfigurationSearch : IDisposable
{
private const int MinimumSearchLength = 2;
private const int MaximumResults = 15;
private readonly Debouncer _searchDebouncer = new(200);
private readonly List<ConfigurationSearchEntry> _searchResults = new();
private bool _isLoading;
private string _searchText = string.Empty;
private IReadOnlyList<ConfigurationSearchEntry> _searchEntries = Array.Empty<ConfigurationSearchEntry>();
[Inject]
private ConfigurationSearchIndexCache SearchIndexCache { get; set; } = null!;
[Inject]
private ILogger<ConfigurationSearch> Logger { get; set; } = null!;
[Inject]
private NavigationManager NavigationManager { get; set; } = null!;
[Inject]
private NavigationHistory NavigationHistory { get; set; } = null!;
[Inject]
private SetupService SetupService { get; set; } = null!;
/// <inheritdoc />
public void Dispose()
{
this.SetupService.DatabaseInitialized -= this.OnDatabaseInitializedAsync;
this._searchDebouncer.Dispose();
}
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
this.SetupService.DatabaseInitialized += this.OnDatabaseInitializedAsync;
if (!this.RendererInfo.IsInteractive)
{
return base.OnInitializedAsync();
}
if (this.SearchIndexCache.IsLoaded)
{
this._searchEntries = this.SearchIndexCache.Entries;
}
else
{
this._isLoading = true;
_ = Task.Run(async () =>
{
try
{
await this.SearchIndexCache.EnsureLoadedAsync().ConfigureAwait(false);
}
catch
{
// Errors are logged inside EnsureLoadedAsync
}
finally
{
await this.InvokeAsync(() =>
{
this._searchEntries = this.SearchIndexCache.Entries;
this._isLoading = false;
this.StateHasChanged();
}).ConfigureAwait(false);
}
});
}
return base.OnInitializedAsync();
}
private static int CalculateScore(ConfigurationSearchEntry entry, string normalizedQuery, IReadOnlyList<string> queryParts)
{
if (queryParts.Count == 0 || !queryParts.All(part => entry.NormalizedHaystack.Contains(part, StringComparison.OrdinalIgnoreCase)))
{
return int.MaxValue;
}
var score = 100;
if (entry.NormalizedCaption.StartsWith(normalizedQuery, StringComparison.OrdinalIgnoreCase))
{
score -= 60;
}
else if (entry.NormalizedCaption.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase))
{
score -= 45;
}
else
{
// Caption does not contain the query, no score adjustment needed.
}
if (entry.NormalizedHaystack.StartsWith(normalizedQuery, StringComparison.OrdinalIgnoreCase))
{
score -= 20;
}
score += entry.Path.Length / 64;
return score;
}
private static string Normalize(string value)
{
return string.Join(
' ',
value.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
private void OnSearchFocus(FocusEventArgs e)
{
this.Logger.LogDebug("Search input focused, event type: {EventType}", e.Type);
this.UpdateSearchResults();
}
private Task OnSearchInputAsync()
{
_ = this._searchDebouncer.DebounceAsync(async token =>
{
if (!token.IsCancellationRequested)
{
await this.InvokeAsync(() =>
{
this.UpdateSearchResults();
this.StateHasChanged();
}).ConfigureAwait(false);
}
});
return Task.CompletedTask;
}
private async Task OnSearchBlurAsync(FocusEventArgs e)
{
this.Logger.LogDebug("Search input blurred, event type: {EventType}", e.Type);
await Task.Delay(100).ConfigureAwait(true);
this._searchResults.Clear();
}
private Task OnSearchKeyDownAsync(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
this._searchText = string.Empty;
this._searchResults.Clear();
}
else if (string.Equals(args.Key, "Enter", StringComparison.Ordinal)
&& this._searchResults.FirstOrDefault() is { } firstResult)
{
this.NavigateToResult(firstResult);
}
else
{
// Other keys are not handled.
}
return Task.CompletedTask;
}
private async ValueTask OnDatabaseInitializedAsync()
{
this._searchEntries = Array.Empty<ConfigurationSearchEntry>();
this._searchResults.Clear();
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
private void UpdateSearchResults()
{
this._searchResults.Clear();
if (this._searchEntries.Count == 0)
{
return;
}
var normalizedQuery = Normalize(this._searchText);
if (normalizedQuery.Length < MinimumSearchLength)
{
return;
}
var queryParts = normalizedQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var results = this._searchEntries
.Select(entry => (Entry: entry, Score: CalculateScore(entry, normalizedQuery, queryParts)))
.Where(result => result.Score < int.MaxValue)
.OrderBy(result => result.Score)
.ThenBy(result => result.Entry.Path, StringComparer.Ordinal)
.Take(MaximumResults)
.Select(result => result.Entry);
this._searchResults.AddRange(results);
}
private void NavigateToResult(ConfigurationSearchEntry entry)
{
this._searchText = string.Empty;
this._searchResults.Clear();
this.NavigationHistory.Clear();
this.NavigationManager.NavigateTo(entry.Url);
}
}

View File

@@ -0,0 +1,60 @@
.configuration-search {
position: relative;
width: min(100%, 48rem);
min-width: 16rem;
z-index: 90;
}
.configuration-search__results {
position: absolute;
top: calc(100% + 0.15rem);
left: 0;
width: 100%;
max-height: min(68vh, 28rem);
z-index: 1060;
overflow-y: auto;
margin: 0;
padding: 0.25rem 0;
}
.configuration-search__result {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 0.14rem;
padding: 0.5rem 1rem;
cursor: pointer;
white-space: normal;
}
.configuration-search__result-caption {
font-weight: 600;
color: inherit;
}
.configuration-search__result-path {
font-size: 0.78rem;
color: var(--omu-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.configuration-search__result:hover .configuration-search__result-path,
.configuration-search__result:focus .configuration-search__result-path {
color: var(--omu-text);
}
@media (max-width: 767.98px) {
.configuration-search {
width: 100%;
min-width: 0;
}
.configuration-search__results {
max-height: min(56vh, 22rem);
}
}

View File

@@ -0,0 +1,72 @@
@* <copyright file="CreationPanel.razor" company="MUnique">
Licensed under the MIT License. See LICENSE file in the project root for full license information.
</copyright> *@
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Shared.Components.Form
@using MUnique.OpenMU.Web.Shared.Services
@implements IDisposable
@inject CreationPanelService Panel
@inject Blazored.Toast.Services.IToastService ToastService
@if (this.Panel.Current is { } session)
{
<div class="creation-panel @(this.Panel.IsCollapsed ? "collapsed" : "expanded")">
<button type="button"
class="collapse-toggle"
title="@(this.Panel.IsCollapsed ? Resources.ShowEntryForm : Resources.HideEntryForm)"
@onclick="@this.Panel.ToggleCollapse">
<span class="oi @(this.Panel.IsCollapsed ? "oi-chevron-left" : "oi-chevron-right")" aria-hidden="true"></span>
</button>
@if (!this.Panel.IsCollapsed)
{
<div class="creation-panel-body">
<h3>@session.Title</h3>
<ItemCreationForm Item="@session.Item"
PersistenceContext="@session.Context"
Owner="@session.Owner"
OnValidSubmit="@this.OnSubmitAsync"
OnCancel="@this.OnCancelAsync" />
</div>
}
</div>
}
@code {
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
this.Panel.StateChanged += this.OnStateChanged;
}
/// <inheritdoc />
public void Dispose()
{
this.Panel.StateChanged -= this.OnStateChanged;
}
private void OnStateChanged()
{
_ = this.InvokeAsync(this.StateHasChanged);
}
private async Task OnSubmitAsync()
{
try
{
await this.Panel.CompleteAsync();
}
catch (Exception ex)
{
this.ToastService.ShowError($"Could not save the new entry: {ex.Message}");
}
}
private Task OnCancelAsync()
{
return this.Panel.CancelAsync();
}
}

View File

@@ -0,0 +1,78 @@
.creation-panel {
position: sticky;
top: 0;
align-self: flex-start;
height: 100vh;
flex-shrink: 0;
display: flex;
flex-direction: row;
background-color: var(--omu-surface-2);
color: var(--omu-text);
border-left: var(--bs-border-width) solid var(--omu-border);
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.08);
overflow: hidden;
z-index: 2;
}
.creation-panel.collapsed {
width: 1.75rem;
}
.collapse-toggle {
flex-shrink: 0;
width: 1.75rem;
padding: 0;
border: none;
border-right: var(--bs-border-width) solid var(--omu-border);
background-color: var(--omu-surface-muted);
cursor: pointer;
color: var(--omu-text);
}
.collapse-toggle:hover {
background-color: var(--omu-surface-hover);
}
.creation-panel-body {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem 1.25rem 0;
display: flex;
flex-direction: column;
}
.creation-panel-body h3 {
margin-top: 0;
margin-bottom: 1rem;
word-break: break-word;
}
.creation-panel-body ::deep form {
flex: 1;
display: flex;
flex-direction: column;
}
.creation-panel-body ::deep .form-actions {
position: sticky;
bottom: 0;
z-index: 3;
background-color: var(--omu-surface-2);
margin: 1rem -1.5rem -1rem -1.5rem;
padding: 1rem 1.25rem;
border-top: var(--bs-border-width) solid var(--omu-border);
display: flex;
gap: 0.5rem;
justify-content: flex-start;
box-sizing: border-box;
}
/* Let the form controls fill the wider panel so the space is actually used. */
.creation-panel-body ::deep input:not([type="checkbox"]):not([type="radio"]),
.creation-panel-body ::deep select,
.creation-panel-body ::deep textarea {
width: 100%;
box-sizing: border-box;
}

View File

@@ -0,0 +1,57 @@
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Shared.Services
@inherits LayoutComponentBase
@code {
[CascadingParameter]
public HttpContext? HttpContext { get; set; }
private bool IsDarkTheme => string.Equals(
this.HttpContext?.Request.Cookies[ThemeController.CookieName],
"dark",
StringComparison.OrdinalIgnoreCase);
}
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<header class="main-header navbar navbar-expand navbar-dark border-bottom bg-body-tertiary">
<div class="container-fluid px-4">
<div class="header-content d-flex align-items-center w-100">
<div class="header-search flex-grow-1">
<ConfigurationSearch />
</div>
<div class="header-actions ms-3 d-flex align-items-center">
<ThemeSelector IsDark="@this.IsDarkTheme" />
<a href="https://munique.net" target="_blank" class="text-secondary small ms-3">@Resources.About</a>
</div>
</div>
</div>
</header>
<div class="breadcrumb-bar border-bottom px-4 py-3">
<div class="d-flex align-items-center">
<BreadcrumbNavigation />
</div>
</div>
<BlazoredToasts />
<article class="content px-4 py-3">
@Body
</article>
</main>
<CreationPanel />
</div>
<div id="blazor-error-ui" data-nosnippet>
@Resources.UnhandledErrorOccurred
<a href="." class="reload">@Resources.Reload</a>
<span class="dismiss">🗙</span>
</div>
<ModalLoadingIndicator />

View File

@@ -0,0 +1,80 @@
.page {
display: flex;
flex-direction: row;
}
main {
flex: 1;
min-width: 0;
}
.sidebar {
background-image: var(--omu-sidebar-gradient);
}
.header-search {
max-width: 48rem;
}
.main-header {
top: 0;
z-index: 99;
position: sticky;
}
.breadcrumb-bar {
min-height: 2.5rem;
background-color: var(--omu-surface-2);
}
.breadcrumb-bar ::deep .breadcrumb-nav {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
.breadcrumb-bar ::deep .breadcrumb {
margin-bottom: 0;
padding: 0.25rem 0;
background-color: transparent;
}
.breadcrumb-bar ::deep .btn-group {
margin-right: 1rem;
}
@media (max-width: 767.98px) {
.header-content {
flex-direction: column;
align-items: stretch !important;
gap: 0.5rem;
padding: 0.5rem 0;
}
.header-actions {
margin-left: 0 !important;
text-align: right;
}
}
#blazor-error-ui {
background: var(--omu-error-bg);
color: var(--omu-error-text);
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,114 @@
@using MUnique.OpenMU.Web.AdminPanel.Properties
<nav class="rounded-bottom shadow-lg">
<div class="top-row ps-4 navbar navbar-dark">
<NavLink class="navbar-brand" @onclick="() => NavigationHistory.Clear()">OpenMU</NavLink>
<button class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<!-- Hiding it programmatically is not a good idea here, because we want to hide it based on the width of the device/window. -->
<div class="@NavMenuCssClass">
<ul class="nav flex-column">
@if (this._isLoadingConfig)
{
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-clock" aria-hidden="true"></span> @Resources.Loading
</NavLink>
</li>
}
else if (this._onlyShowSetup)
{
<li class="nav-item px-3">
<NavLink class="nav-link" href="setup" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-cog" aria-hidden="true"></span> @Resources.Setup
</NavLink>
</li>
}
else
{
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-home" aria-hidden="true"></span> @Resources.Home
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="servers" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-project" aria-hidden="true"></span> @Resources.Servers
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="accounts" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-list" aria-hidden="true"></span> @Resources.Accounts
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="logged-in" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-people" aria-hidden="true" /> @Resources.OnlineAccounts
</NavLink>
</li>
@if (this.GameConfigurationId is { } gameConfigurationId )
{
<ConfigNavMenu GameConfigurationId="@gameConfigurationId" />
}
<li class="nav-item px-3">
<NavLink class="nav-link" href="setup" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-cog" aria-hidden="true"></span> @Resources.Setup
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="config-updates" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-medical-cross" aria-hidden="true"></span> @Resources.Updates
@if (this._availableConfigUpdates > 0)
{
<span class="badge text-bg-light ms-2">@this._availableConfigUpdates</span>
<span class="visually-hidden">available updates</span>
}
</NavLink>
</li>
}
@if (UserService.IsAvailable)
{
<li class="nav-item px-3">
<NavLink class="nav-link" href="users" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-key" aria-hidden="true"></span> @Resources.Users
</NavLink>
</li>
}
@if (AdminPanelEnvironment.IsHostingEmbedded)
{
<li class="nav-item px-3">
<NavLink class="nav-link" href="logfiles" @onclick="() => NavigationHistory.Clear()">
<span class="oi oi-list" aria-hidden="true"></span> @Resources.LogFiles
</NavLink>
</li>
}
else
{
<li class="nav-item px-3">
<a class="nav-link" href="/grafana/explore?orgId=1&left=%7B%22datasource%22:%22Loki%22,%22queries%22:%5B%7B%22refId%22:%22A%22%7D%5D,%22range%22:%7B%22from%22:%22now-1h%22,%22to%22:%22now%22%7D%7D"
target="_blank">
<span class="oi oi-list" aria-hidden="true"></span> @Resources.Logs
</a>
</li>
<li class="nav-item px-3">
<a class="nav-link" href="/grafana/dashboards"
target="_blank">
<span class="oi oi-bar-chart" aria-hidden="true"></span> @Resources.Metrics
</a>
</li>
<li class="nav-item px-3">
<a class="nav-link" href="/zipkin/"
target="_blank">
<span class="oi oi-fork" aria-hidden="true"></span> @Resources.Tracing
</a>
</li>
}
<li class="nav-item px-3">
<CultureSelector />
</li>
</ul>
</div>
</nav>

View File

@@ -0,0 +1,147 @@
// <copyright file="NavMenu.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Components.Layout;
using System.Threading;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.Web.AdminPanel.Services;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Navigation menu of the admin panel.
/// </summary>
public partial class NavMenu : IDisposable
{
private bool _collapseNavMenu = true;
private bool _isLoadingConfig = false;
private bool _onlyShowSetup;
private int _availableConfigUpdates;
[Inject]
private IMigratableDatabaseContextProvider PersistenceContextProvider { get; set; } = null!;
[Inject]
private SetupService SetupService { get; set; } = null!;
[Inject]
private IUserService UserService { get; set; } = null!;
[Inject]
private DataUpdateService UpdateService { get; set; } = null!;
[Inject]
private NavigationHistory NavigationHistory { get; set; } = null!;
private Guid? GameConfigurationId { get; set; }
/// <summary>
/// Gets the class for the entries of the navigation menu.
/// "collapse" is a class of bootstrap which hides it.
/// In our CSS we also define to show it anyway, if the width sufficient.
/// </summary>
private string NavMenuCssClass => this._collapseNavMenu ? "collapse" : string.Empty;
/// <inheritdoc />
public void Dispose()
{
this.SetupService.DatabaseInitialized -= this.OnDatabaseInitializedAsync;
this.UpdateService.UpdatesInstalled -= this.OnUpdatesInstalledAsync;
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync().ConfigureAwait(false);
this.SetupService.DatabaseInitialized += this.OnDatabaseInitializedAsync;
this.UpdateService.UpdatesInstalled += this.OnUpdatesInstalledAsync;
_ = Task.Run(async () =>
{
await this.LoadGameConfigurationAsync().ConfigureAwait(false);
await this.CheckForUpdatesAsync().ConfigureAwait(false);
});
}
private async ValueTask OnUpdatesInstalledAsync()
{
this._availableConfigUpdates = 0;
_ = Task.Run(this.CheckForUpdatesAsync);
}
private async ValueTask OnDatabaseInitializedAsync()
{
// We have to reload, because the old links are not correct anymore.
this.GameConfigurationId = null;
await this.LoadGameConfigurationAsync().ConfigureAwait(false);
await this.CheckForUpdatesAsync().ConfigureAwait(false);
}
private async Task LoadGameConfigurationAsync()
{
if (this.GameConfigurationId is not null || this._isLoadingConfig)
{
return;
}
this._isLoadingConfig = true;
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
if (!await this.PersistenceContextProvider.CanConnectToDatabaseAsync(cts.Token).ConfigureAwait(true)
|| !await this.PersistenceContextProvider.DatabaseExistsAsync(cts.Token).ConfigureAwait(true))
{
return;
}
using var context = this.PersistenceContextProvider.CreateNewConfigurationContext();
this.GameConfigurationId = await context.GetDefaultGameConfigurationIdAsync(cts.Token).ConfigureAwait(true);
}
catch
{
this.GameConfigurationId = null;
}
finally
{
this._onlyShowSetup = this.GameConfigurationId is null;
this._isLoadingConfig = false;
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
}
private async Task CheckForUpdatesAsync()
{
try
{
if (this.GameConfigurationId is null)
{
this._availableConfigUpdates = 0;
return;
}
var updates = await this.UpdateService.DetermineAvailableUpdatesAsync().ConfigureAwait(false);
this._availableConfigUpdates = updates.Count;
}
catch
{
this._availableConfigUpdates = 0;
}
finally
{
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(true);
}
}
private void ToggleNavMenu()
{
this._collapseNavMenu = !this._collapseNavMenu;
}
}

View File

@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 768px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: var(--omu-bg);
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: var(--omu-link-primary);
color: #fff;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: var(--omu-link-primary-border);
}
#components-reconnect-modal button:active {
background-color: var(--omu-link-primary);
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid var(--omu-link);
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,8 @@
<Router AppAssembly="@typeof(Program).Assembly" NotFoundPage="@typeof(Pages.NotFound)">
<Found Context="routeData">
<ModalContainer>
<RouteView RouteData="routeData" DefaultLayout="@typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</ModalContainer>
</Found>
</Router>

View File

@@ -0,0 +1,232 @@
@using System.ComponentModel
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.DataModel.Configuration
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.Persistence
@using MUnique.OpenMU.Web.AdminPanel.Properties
@implements IDisposable
@if (this._isDeleted)
{
return;
}
<tr class=@(this.Server.ServerState == ServerState.Started ? "success" : "warning") id=@Server.Id>
<td>
@if (this.Server.Type == ServerType.GameServer)
{
// Link to a reverse proxied server page
// Take a look at the nginx.conf
// For an all-in-one-deployment, this link leads to the MapPage.
<a href="/gameServer/@(this.Server.Id)/" target="_blank"><span class="oi oi-map"></span></a>
}
</td>
<td>
@switch (this.Server.Type)
{
case ServerType.GameServer:
<NavLink href="@("edit-config/" + typeof(GameServerDefinition).FullName + "/" + this.Server.ConfigurationId)">@this.Server.Description</NavLink>
break;
case ServerType.ConnectServer:
<NavLink href="@("edit-connectionServer/" + this.Server.ConfigurationId)">@this.Server.Description</NavLink>
break;
case ServerType.ChatServer:
<NavLink href="@("edit-config/" + typeof(ChatServerDefinition).FullName + "/" + this.Server.ConfigurationId)">@this.Server.Description</NavLink>
break;
default:
@this.Server.Description
break;
}
</td>
<td>
<div>@this.Server.CurrentConnections / @(this.Server.MaximumConnections < int.MaxValue ? this.Server.MaximumConnections.ToString() : "∞")</div>
</td>
<td>@this.Server.ServerState.GetEnumCaption()</td>
<td>
<div class="btn-group" role="group" aria-label="@Resources.ServerControl">
@if (this.Server.ServerState == ServerState.Started)
{
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Start" disabled>
<span class="oi oi-media-play"></span>
</button>
<button type="button" class="btn btn-warning btn-sm" title="@Resources.Stop" @onclick="this.OnPauseClickAsync">
<span class="oi oi-media-pause"></span>
</button>
@if (this.Server.Type is ServerType.GameServer or ServerType.ConnectServer)
{
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Remove" disabled>
<span class="oi oi-trash"></span>
</button>
}
}
else if ((this.Server.ServerState == ServerState.Stopped))
{
<button type="button" class="btn btn-success btn-sm" title="@Resources.Start" @onclick="this.OnStartClickAsync">
<span class="oi oi-media-play"></span>
</button>
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Stop" disabled>
<span class="oi oi-media-pause"></span>
</button>
@if (this.Server.Type is ServerType.GameServer or ServerType.ConnectServer)
{
<button type="button" class="btn btn-danger btn-sm" title="@Resources.Remove" @onclick="this.OnDeleteServerClickAsync">
<span class="oi oi-trash"></span>
</button>
}
}
else
{
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Start" disabled>
<span class="oi oi-media-play"></span>
</button>
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Stop" disabled>
<span class="oi oi-media-pause"></span>
</button>
@if (this.Server.Type is ServerType.GameServer or ServerType.ConnectServer)
{
<button type="button" class="btn btn-secondary btn-sm" title="@Resources.Remove" disabled>
<span class="oi oi-trash"></span>
</button>
}
}
</div>
</td>
</tr>
@code {
private bool _isDeleted;
/// <summary>
/// Gets or sets the server which is shown in this component.
/// </summary>
[Parameter]
public IManageableServer Server { get; set; } = null!;
/// <summary>
/// Gets or sets the <see cref="IGameServerInstanceManager"/>.
/// </summary>
[Inject]
public IGameServerInstanceManager GameServerInstanceManager { get; set; } = null!;
/// <summary>
/// Gets or sets the <see cref="IConnectServerInstanceManager"/>.
/// </summary>
[Inject]
public IConnectServerInstanceManager ConnectServerInstanceManager { get; set; } = null!;
/// <summary>
/// Gets or sets the <see cref="IPersistenceContextProvider"/>.
/// </summary>
[Inject]
public IPersistenceContextProvider ContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the data source for the game configuration.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> DataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the modal service.
/// </summary>
[Inject]
public IModalService ModalService { get; set; } = null!;
/// <inheritdoc />
public void Dispose()
{
this.Server.PropertyChanged -= this.OnServerPropertyChanged;
}
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
this.Server.PropertyChanged += this.OnServerPropertyChanged;
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
this._isDeleted = false;
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnServerPropertyChanged(object? sender, PropertyChangedEventArgs eventArgs)
{
try
{
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
catch
{
// must be catched because it's an async void method.
}
}
private string GetActionClass()
{
if (this.Server.ServerState == ServerState.Started)
return "btn-success";
else
return "btn-warning";
}
private async Task OnPauseClickAsync()
{
await this.Server.StopAsync(default);
}
private async Task OnStartClickAsync()
{
await this.Server.StartAsync(default);
}
private async Task OnDeleteServerClickAsync()
{
if (this.Server.Type is not (ServerType.GameServer or ServerType.ConnectServer))
{
return;
}
var dialogResult = await this.ModalService.ShowQuestionAsync(
@Resources.RemoveServer,
@Resources.ServerDeleteProceedQuestion);
if (!dialogResult)
{
return;
}
await this.Server.StopAsync(default);
if (this.Server.Type == ServerType.GameServer)
{
await this.GameServerInstanceManager.RemoveGameServerAsync((byte)this.Server.Id);
await this.DeleteAsync<GameServerDefinition>(s => s.ServerID == this.Server.Id);
}
else
{
await this.ConnectServerInstanceManager.RemoveConnectServerAsync(this.Server.ConfigurationId);
await this.DeleteAsync<ConnectServerDefinition>(s => s.ConfigurationId == this.Server.ConfigurationId);
}
}
private async ValueTask DeleteAsync<T>(Predicate<T> predicate)
where T : class
{
var gameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(false);
using var context = this.ContextProvider.CreateNewTypedContext(typeof(T), true, gameConfiguration);
var definitions = await context.GetAsync<T>().ConfigureAwait(false);
var definition = definitions.FirstOrDefault(def => predicate(def));
if (definition is not null)
{
await context.DeleteAsync(definition).ConfigureAwait(false);
await context.SaveChangesAsync().ConfigureAwait(false);
this._isDeleted = true;
}
}
}

View File

@@ -0,0 +1,60 @@
@using MUnique.OpenMU.Interfaces
@using System.ComponentModel
@implements IDisposable
<tr class="info">
<td colspan="5">
<span>@MUnique.OpenMU.Web.AdminPanel.Properties.Resources.TotalPlayers: @this.GetPlayerCount()</span>
</td>
</tr>
@code {
/// <summary>
/// Gets or sets the servers.
/// </summary>
[Parameter]
public IList<IManageableServer> Servers { get; set; } = null!;
/// <inheritdoc />
public void Dispose()
{
foreach (var server in this.Servers)
{
server.PropertyChanged -= this.OnServerPropertyChanged;
}
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
foreach (var server in this.Servers)
{
server.PropertyChanged += this.OnServerPropertyChanged;
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnServerPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
try
{
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
catch
{
// must be catched because it's an async void method.
}
}
private int GetPlayerCount()
{
return this.Servers?
.Where(s => s.Id < 0x10000
&& s.ServerState != ServerState.Timeout)
.Sum(s => s.CurrentConnections) ?? 0;
}
}

View File

@@ -0,0 +1,42 @@
// <copyright file="Exports.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel;
using System.Collections.Immutable;
/// <summary>
/// Class that holds the script exports of this project.
/// </summary>
/// <remarks>
/// TODO: Instead of a static class, create an interface, so we can inject an instance into the layout.
/// For example, we could further add some common Components which render the Scripts, Stylesheets, etc.
/// </remarks>
public static class Exports
{
/// <summary>
/// Gets the scripts.
/// </summary>
public static ImmutableList<string> Scripts { get; } = AdminPanelEnvironment.IsHostingEmbedded
? Web.Map.Exports.Scripts.Concat(AdminPanelScripts).ToImmutableList()
: AdminPanelScripts.ToImmutableList();
/// <summary>
/// Gets the script mappings.
/// </summary>
public static ImmutableList<(string Key, string Path)> ScriptMappings { get; } = AdminPanelEnvironment.IsHostingEmbedded
? Web.Map.Exports.ScriptMappings.ToImmutableList()
: ImmutableList<(string Key, string Path)>.Empty;
/// <summary>
/// Gets the stylesheets.
/// </summary>
public static ImmutableList<string> Stylesheets { get; } = AdminPanelEnvironment.IsHostingEmbedded
? Web.Map.Exports.Stylesheets.Concat(AdminPanelStylesheets).ToImmutableList()
: AdminPanelStylesheets.ToImmutableList();
private static IEnumerable<string> AdminPanelScripts => [];
private static IEnumerable<string> AdminPanelStylesheets => [];
}

View File

@@ -0,0 +1,79 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!-- Set base path for static web assets to avoid conflicts when referenced by other web apps.
Required for .NET 10+ where WebApp-to-WebApp references are unsupported.
Also resolves asset path conflicts in .NET 9 when AdminPanel and Map both have wwwroot/css/site.css.
Note: Standalone Program.cs in this project won't serve assets correctly.
Use Startup or AdminPanel.Host projects to run the admin panel. -->
<StaticWebAssetBasePath>_content/$(MSBuildProjectName)</StaticWebAssetBasePath>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\..\bin\Debug\MUnique.OpenMU.Web.AdminPanel.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\..\bin\Release\MUnique.OpenMU.Web.AdminPanel.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="Blazored.Toast" />
<PackageReference Include="BlazorInputFile" />
<PackageReference Include="Microsoft.AspNetCore.Components.QuickGrid" />
<PackageReference Include="Nito.AsyncEx" />
<PackageReference Include="SixLabors.ImageSharp" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\DataModel\MUnique.OpenMU.DataModel.csproj" />
<ProjectReference Include="..\..\GameLogic\MUnique.OpenMU.GameLogic.csproj" />
<ProjectReference Include="..\..\Persistence\Initialization\MUnique.OpenMU.Persistence.Initialization.csproj" />
<ProjectReference Include="..\..\Persistence\MUnique.OpenMU.Persistence.csproj" />
<ProjectReference Include="..\Map\MUnique.OpenMU.Web.Map.csproj" />
<ProjectReference Include="..\Shared\MUnique.OpenMU.Web.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="Components\ItemEdit\" />
</ItemGroup>
<Target Name="FixNetSdkDiscoverAssetsBug" BeforeTargets="ResolveBuildCompressedStaticWebAssetsConfiguration">
<!-- Workaround for:
The "DiscoverPrecompressedAssets" task failed unexpectedly.
System.ArgumentException: An item with the same key has already been added. Key: C:\Users\[user]\.nuget\packages\microsoft.aspnetcore.app.internal.assets\10.0.0\_framework\blazor.web.js
https://github.com/dotnet/sdk/issues/52089
This is caused by a Microsoft.NET.Sdk.Web project (like this) referencing another project that also uses Microsoft.NET.Sdk.Web.
It appears to be a .NET SDK bug, and we're not the only one hitting it.
-->
<ItemGroup>
<!-- Remove any duplicate StaticWebAsset and StaticWebAssetEndpoint items -->
<_StaticWebAsset Include="@(StaticWebAsset)" />
<StaticWebAsset Remove="@(StaticWebAsset)" />
<StaticWebAsset Include="@(_StaticWebAsset-&gt;Distinct())" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,39 @@
@page "/accounts"
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.DataModel.Entities;
@using MUnique.OpenMU.Persistence;
@using Resources = MUnique.OpenMU.Web.AdminPanel.Properties.Resources
@inject AccountService _accountService;
@{
var title = typeof(Account).GetPluralizedTypeCaption();
}
<PageTitle>OpenMU: @title</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@title" />
<h1>@title</h1>
<div>
<DataTable TItem=@Account>
<TableHeader>
<th class="col-1">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-1">@typeof(Account).GetPropertyCaption(nameof(Account.State))</th>
<th class="col-2">@typeof(Account).GetPropertyCaption(nameof(Account.EMail))</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@item.State.GetEnumCaption()</td>
<td>@item.EMail</td>
<td>
<NavLink href="@($"edit-account/{item.GetId()}/{typeof(Account).FullName}/{item.GetId()}")" class="btn btn-primary"><span class="oi oi-pencil" aria-hidden="true"></span> Edit</NavLink>
</td>
</ItemTemplate>
<TableFooter>
<td><button type="button" class="btn btn-primary" @onclick="@this._accountService.CreateNewInModalDialogAsync">@Resources.Create</button></td>
<td></td>
<td></td>
<td></td>
</TableFooter>
</DataTable>
</div>

View File

@@ -0,0 +1,47 @@
@page "/users"
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.DataModel.Entities
@using MUnique.OpenMU.Web.AdminPanel.Properties
@inject IUserService UserService;
@{
var title = @MUnique.OpenMU.Web.AdminPanel.Properties.Resources.AdminUsers;
}
<PageTitle>OpenMU: @title</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@title"/>
<h1>@title</h1>
<div>
<table class="table table-striped table-hover">
<thead>
<th class="col-10">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-1">@Resources.Actions</th>
<th class="col-1"></th>
</thead>
<tbody>
@{ var users = this.UserService.Users; }
@foreach (var user in users)
{
<tr>
<td>@user</td>
<td>
<button type="button" class="btn btn-sm btn-primary" @onclick="async () => await this.UserService.ChangePasswordInModalDialogAsync(user)">@Resources.ChangePassword</button>
</td>
<td>
@if (users.Count > 1)
{
<button type="button" class="btn btn-sm btn-danger" @onclick="async () => await this.UserService.DeleteUserAsync(user)">@Resources.Delete</button>
}
</td>
</tr>
}
</tbody>
<tfoot>
<td><button type="button" class="btn btn-sm btn-success" @onclick="@this.UserService.CreateNewInModalDialogAsync">@Resources.CreateUser</button></td>
<td></td>
<td></td>
</tfoot>
</table>
</div>

View File

@@ -0,0 +1,28 @@
@page "/create-connect-server"
@using MUnique.OpenMU.Web.AdminPanel.Properties
@{
var title = Resources.CreateConnectServer;
}
<PageTitle>OpenMU: @title</PageTitle>
<Breadcrumb Caption="@title"/>
<h1>@title</h1>
@if (this._viewModel is null)
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@Resources.Loading</span>
return;
}
@if (this._initState is { })
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@this._initState</span>
return;
}
<AutoForm Model="this._viewModel" OnValidSubmit="this.OnSaveButtonClickAsync"></AutoForm>

View File

@@ -0,0 +1,209 @@
// <copyright file="CreateConnectServerConfig.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
/// <summary>
/// Razor page which shows objects of the specified type in a grid.
/// </summary>
public partial class CreateConnectServerConfig : ComponentBase, IAsyncDisposable
{
private Task? _loadTask;
private CancellationTokenSource? _disposeCts;
private ConnectServerViewModel? _viewModel;
private string? _initState;
/// <summary>
/// Gets or sets the context provider.
/// </summary>
[Inject]
public IPersistenceContextProvider ContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the server initializer.
/// </summary>
[Inject]
public IConnectServerInstanceManager ServerInstanceManager { get; set; } = null!;
/// <summary>
/// Gets or sets the data source.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> DataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = null;
try
{
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// we can ignore that ...
}
catch
{
// and we should not throw exceptions in the dispose method ...
}
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token);
await base.OnParametersSetAsync().ConfigureAwait(true);
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var gameConfiguration = await this.DataSource.GetOwnerAsync(default, cancellationToken).ConfigureAwait(true);
using var persistenceContext = this.ContextProvider.CreateNewContext(gameConfiguration);
var clients = (await persistenceContext.GetAsync<GameClientDefinition>(cancellationToken).ConfigureAwait(false)).ToList();
var existingServerDefinitions = (await persistenceContext.GetAsync<ConnectServerDefinition>(cancellationToken).ConfigureAwait(false)).ToList();
var nextServerId = 0;
var networkPort = 55901;
if (existingServerDefinitions.Count > 0)
{
nextServerId = existingServerDefinitions.Max(s => s.ServerId) + 1;
networkPort = existingServerDefinitions.Max(s => s.ClientListenerPort) + 1;
}
var unusedClient = clients.FirstOrDefault(c => !existingServerDefinitions.Any(s => object.Equals(s.Client, c)));
this._viewModel = new ConnectServerViewModel
{
ServerId = (byte)nextServerId,
NetworkPort = networkPort,
Client = unusedClient ?? clients.FirstOrDefault(),
};
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
private async ValueTask<ConnectServerDefinition> CreateDefinitionByViewModelAsync(IContext context)
{
if (this._viewModel is null)
{
throw new InvalidOperationException("View model not initialized.");
}
var result = context.CreateNew<ConnectServerDefinition>();
result.InitializeDefaults();
result.ServerId = this._viewModel.ServerId;
result.Description = this._viewModel.Description;
result.Client = this._viewModel.Client!;
result.ClientListenerPort = this._viewModel.NetworkPort;
return result;
}
private async Task OnSaveButtonClickAsync()
{
try
{
var gameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(false);
using var saveContext = this.ContextProvider.CreateNewTypedContext(typeof(DataModel.Configuration.ConnectServerDefinition), true, gameConfiguration);
var existingServerDefinitions = (await saveContext.GetAsync<ConnectServerDefinition>().ConfigureAwait(false)).ToList();
if (existingServerDefinitions.Any(def => def.ServerId == this._viewModel?.ServerId))
{
this.ToastService.ShowError(string.Format(Resources.ServerWithIdAlreadyExists, this._viewModel?.ServerId));
return;
}
if (existingServerDefinitions.Any(def => def.ClientListenerPort == this._viewModel?.NetworkPort))
{
this.ToastService.ShowError(string.Format(Resources.ServerWithPortAlreadyExists, this._viewModel?.NetworkPort));
return;
}
this._initState = Resources.CreatingConfigurationInfo;
await this.InvokeAsync(this.StateHasChanged);
var connectServerDefinition = await this.CreateDefinitionByViewModelAsync(saveContext).ConfigureAwait(false);
this._initState = Resources.SavingConfigurationInfo;
await this.InvokeAsync(this.StateHasChanged);
var success = await saveContext.SaveChangesAsync().ConfigureAwait(true);
// if success, init new game server instance
if (success)
{
this.ToastService.ShowSuccess(Resources.ConnectionServerConfigurationSaved);
this._initState = Resources.InitializingConnectServerInfo;
await this.InvokeAsync(this.StateHasChanged);
await this.ServerInstanceManager.InitializeConnectServerAsync(connectServerDefinition.ConfigurationId);
this.NavigationManager.NavigateTo("servers");
return;
}
this.ToastService.ShowError(Resources.NoChangesSaved);
}
catch (Exception ex)
{
this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorOccurred, ex.Message));
}
this._initState = null;
}
/// <summary>
/// The view model for a <see cref="ConnectServerDefinition"/>.
/// </summary>
public class ConnectServerViewModel
{
/// <summary>
/// Gets or sets the server identifier.
/// </summary>
public byte ServerId { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client which is expected to connect.
/// </summary>
[Required]
public GameClientDefinition? Client { get; set; }
/// <summary>
/// Gets or sets the network port on which the server is listening.
/// </summary>
[Range(1, ushort.MaxValue)]
public int NetworkPort { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
@page "/create-game-server"
@using MUnique.OpenMU.Web.AdminPanel.Properties
@{
var title = Resources.CreateGameServer;
}
<PageTitle>OpenMU: @title</PageTitle>
<Breadcrumb Caption="@title"/>
<h1>@title</h1>
@if (this._viewModel is null)
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@Resources.Loading</span>
return;
}
@if (this._initState is { })
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@this._initState</span>
return;
}
<AutoForm Model="this._viewModel" OnValidSubmit="this.OnSaveButtonClickAsync"></AutoForm>

View File

@@ -0,0 +1,245 @@
// <copyright file="CreateGameServerConfig.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Modal;
/// <summary>
/// Razor page that shows objects of the specified type in a grid.
/// </summary>
public partial class CreateGameServerConfig : ComponentBase, IAsyncDisposable
{
private Task? _loadTask;
private CancellationTokenSource? _disposeCts;
private GameServerViewModel? _viewModel;
private string? _initState;
/// <summary>
/// Gets or sets the context provider.
/// </summary>
[Inject]
public IPersistenceContextProvider ContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the server initializer.
/// </summary>
[Inject]
public IGameServerInstanceManager ServerInstanceManager { get; set; } = null!;
/// <summary>
/// Gets or sets the data source.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> DataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the modal service.
/// </summary>
[Inject]
public IModalService ModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = null;
try
{
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// We can ignore that.
}
catch
{
// And we should not throw exceptions in the dispose method.
}
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token);
await base.OnParametersSetAsync().ConfigureAwait(true);
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var gameConfiguration = await this.DataSource.GetOwnerAsync(default, cancellationToken).ConfigureAwait(true);
using var persistenceContext = this.ContextProvider.CreateNewContext(gameConfiguration);
var serverConfigs = await persistenceContext.GetAsync<GameServerConfiguration>(cancellationToken).ConfigureAwait(false);
var clients = await persistenceContext.GetAsync<GameClientDefinition>(cancellationToken).ConfigureAwait(false);
var existingServerDefinitions = (await persistenceContext.GetAsync<GameServerDefinition>(cancellationToken).ConfigureAwait(false)).ToList();
var nextServerId = 0;
var networkPort = 55901;
if (existingServerDefinitions.Count > 0)
{
nextServerId = existingServerDefinitions.Max(s => s.ServerID) + 1;
networkPort = existingServerDefinitions.Max(s => s.Endpoints.FirstOrDefault()?.NetworkPort ?? 55900) + 1;
}
this._viewModel = new GameServerViewModel
{
ServerConfiguration = serverConfigs.FirstOrDefault(),
ServerId = (byte)nextServerId,
ExperienceRate = 1.0f,
PvpEnabled = true,
NetworkPort = networkPort,
Client = clients.FirstOrDefault(),
};
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
private async ValueTask<GameServerDefinition> CreateDefinitionByViewModelAsync(IContext context)
{
if (this._viewModel is null)
{
throw new InvalidOperationException("View model not initialized.");
}
var result = context.CreateNew<GameServerDefinition>();
result.ServerID = this._viewModel.ServerId;
result.Description = this._viewModel.Description;
result.PvpEnabled = this._viewModel.PvpEnabled;
result.ExperienceRate = this._viewModel.ExperienceRate;
result.GameConfiguration = await this.DataSource.GetOwnerAsync();
result.ServerConfiguration = this._viewModel.ServerConfiguration!;
var endpoint = context.CreateNew<GameServerEndpoint>();
endpoint.NetworkPort = (ushort)this._viewModel.NetworkPort;
endpoint.Client = this._viewModel.Client!;
result.Endpoints.Add(endpoint);
return result;
}
private async Task OnSaveButtonClickAsync()
{
try
{
var gameConfiguration = await this.DataSource.GetOwnerAsync().ConfigureAwait(false);
using var saveContext = this.ContextProvider.CreateNewTypedContext(typeof(DataModel.Configuration.GameServerDefinition), true, gameConfiguration);
var existingServerDefinitions = (await saveContext.GetAsync<GameServerDefinition>().ConfigureAwait(false)).ToList();
if (existingServerDefinitions.Any(def => def.ServerID == this._viewModel?.ServerId))
{
this.ToastService.ShowError(string.Format(Resources.ServerWithIdAlreadyExists, this._viewModel?.ServerId));
return;
}
if (existingServerDefinitions.Any(def => def.Endpoints.Any(endpoint => endpoint.NetworkPort == this._viewModel?.NetworkPort)))
{
this.ToastService.ShowError(string.Format(Resources.ServerWithPortAlreadyExists, this._viewModel?.NetworkPort));
return;
}
this._initState = Resources.CreatingConfigurationInfo;
await this.InvokeAsync(this.StateHasChanged);
var gameServerDefinition = await this.CreateDefinitionByViewModelAsync(saveContext).ConfigureAwait(false);
this._initState = Resources.SavingConfigurationInfo;
await this.InvokeAsync(this.StateHasChanged);
var success = await saveContext.SaveChangesAsync().ConfigureAwait(true);
// if success, init new game server instance
if (success)
{
this.ToastService.ShowSuccess(Resources.GameServerConfigurationSavedInfo);
this._initState = Resources.InitializingGameServerInfo;
await this.InvokeAsync(this.StateHasChanged);
await this.ServerInstanceManager.InitializeGameServerAsync(gameServerDefinition.ServerID);
this.NavigationManager.NavigateTo("servers");
return;
}
this.ToastService.ShowError(Resources.NoChangesSaved);
}
catch (Exception ex)
{
this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorOccurred, ex.Message));
}
this._initState = null;
}
/// <summary>
/// The view model for a <see cref="GameServerDefinition"/>.
/// </summary>
public class GameServerViewModel
{
/// <summary>
/// Gets or sets the server identifier.
/// </summary>
public byte ServerId { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the experience rate.
/// </summary>
/// <value>
/// The experience rate.
/// </value>
[Range(0, float.MaxValue)]
public float ExperienceRate { get; set; }
/// <summary>
/// Gets or sets a value indicating whether PVP is enabled on this server.
/// </summary>
public bool PvpEnabled { get; set; }
/// <summary>
/// Gets or sets the server configuration.
/// </summary>
[Required]
public GameServerConfiguration? ServerConfiguration { get; set; }
/// <summary>
/// Gets or sets the client which is expected to connect.
/// </summary>
[Required]
public GameClientDefinition? Client { get; set; }
/// <summary>
/// Gets or sets the network port on which the server is listening.
/// </summary>
[Range(1, ushort.MaxValue)]
public int NetworkPort { get; set; }
}
}

View File

@@ -0,0 +1,63 @@
// <copyright file="EditAccount.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Threading;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Form;
using MUnique.OpenMU.Web.Shared.Components.ItemEdit;
/// <summary>
/// The edit page for account data.
/// </summary>
[Route("/edit-account/{accountId:guid}/{typeString}/{id:guid}")]
public partial class EditAccount : EditBase
{
/// <summary>
/// Gets or sets the identifier of the account which should be edited.
/// </summary>
[Parameter]
public Guid AccountId { get; set; }
/// <summary>
/// Gets or sets the data source for account data.
/// </summary>
[Inject]
public IDataSource<Account> AccountData { get; set; } = null!;
/// <inheritdoc />
protected override IDataSource EditDataSource => this.AccountData;
/// <inheritdoc />
protected override async ValueTask LoadOwnerAsync(CancellationToken cancellationToken)
{
await this.AccountData.GetOwnerAsync(this.AccountId, cancellationToken).ConfigureAwait(true);
}
/// <inheritdoc />
protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence)
{
if (this.Type == typeof(Item))
{
builder.OpenComponent(++currentSequence, typeof(ItemEdit));
builder.AddAttribute(++currentSequence, nameof(ItemEdit.Item), this.Model);
builder.AddAttribute(++currentSequence, nameof(ItemEdit.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.CloseComponent();
}
else
{
// TODO: Instead of AutoForm, create more specialized components
builder.OpenComponent(++currentSequence, typeof(AutoForm<>).MakeGenericType(this.Type!));
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.Model), this.Model);
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.OnRefresh), EventCallback.Factory.Create(this, this.RefreshAsync));
builder.CloseComponent();
}
}
}

View File

@@ -0,0 +1,435 @@
// <copyright file="EditBase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Abstract common base class for an edit page.
/// </summary>
public abstract class EditBase : ComponentBase, IAsyncDisposable
{
private object? _model;
private Type? _type;
private bool _isOwningContext;
private IContext? _persistenceContext;
private CancellationTokenSource? _disposeCts;
private DataLoadingState _loadingState;
private Task? _loadTask;
private IDisposable? _modalDisposable;
private IDisposable? _navigationLockDisposable;
private enum DataLoadingState
{
NotLoadedYet,
LoadingStarted,
Loading,
Loaded,
NotFound,
Error,
Cancelled,
}
/// <summary>
/// Gets or sets the identifier of the object which should be edited.
/// </summary>
[Parameter]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the <see cref="Type.FullName"/> of the object which should be edited.
/// </summary>
[Parameter]
public string TypeString { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the persistence context provider which loads and saves the object.
/// </summary>
[Inject]
public IPersistenceContextProvider PersistenceContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the modal service.
/// </summary>
[Inject]
public IModalService ModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the loading overlay service.
/// </summary>
[Inject]
public LoadingOverlayService LoadingOverlay { get; set; } = null!;
/// <summary>
/// Gets or sets the configuration data source.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> ConfigDataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation history.
/// </summary>
[Inject]
public NavigationHistory NavigationHistory { get; set; } = null!;
/// <summary>
/// Gets or sets the java script runtime.
/// </summary>
[Inject]
public IJSRuntime JavaScript { get; set; } = null!;
/// <summary>
/// Gets or sets the logger.
/// </summary>
[Inject]
public ILogger<EditBase>? Logger { get; set; }
/// <summary>
/// Gets the data source of the type which is edited.
/// </summary>
protected virtual IDataSource EditDataSource => this.ConfigDataSource;
/// <summary>
/// Gets the model which should be edited.
/// </summary>
protected object? Model => this._model;
/// <summary>
/// Gets the type.
/// </summary>
protected virtual Type? Type => this._type ??= this.DetermineTypeByTypeString();
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
this._navigationLockDisposable?.Dispose();
this._navigationLockDisposable = null;
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = null;
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false);
await this.EditDataSource.DiscardChangesAsync().ConfigureAwait(true);
if (this._isOwningContext)
{
this._persistenceContext?.Dispose();
}
this._persistenceContext = null;
}
/// <inheritdoc />
public override async Task SetParametersAsync(ParameterView parameters)
{
this._model = null;
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(true);
await base.SetParametersAsync(parameters).ConfigureAwait(true);
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
this._loadingState = DataLoadingState.LoadingStarted;
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._type = null;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token);
await base.OnParametersSetAsync().ConfigureAwait(true);
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (this.Model is null)
{
return;
}
builder.OpenComponent<Breadcrumb>(0);
builder.AddAttribute(1, nameof(Breadcrumb.Caption), this.Model.GetName());
builder.CloseComponent();
var downloadMarkup = this.GetDownloadMarkup();
var editorsMarkup = this.GetEditorsMarkup();
builder.AddMarkupContent(10, $"<h1>{Resources.Edit} {this.Type!.GetTypeCaption()}</h1>{downloadMarkup}{editorsMarkup}\r\n");
builder.OpenComponent<CascadingValue<IContext>>(11);
builder.AddAttribute(12, nameof(CascadingValue<IContext>.Value), this._persistenceContext);
builder.AddAttribute(13, nameof(CascadingValue<IContext>.IsFixed), this._isOwningContext);
RenderFragment childContent = builder2 =>
{
var sequence = 14;
this.AddFormToRenderTree(builder2, ref sequence);
};
builder.AddAttribute(14, nameof(CascadingValue<IContext>.ChildContent), childContent);
builder.CloseComponent();
}
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync);
return base.OnInitializedAsync();
}
/// <summary>
/// Adds the form to the render tree.
/// </summary>
/// <param name="builder">The builder.</param>
/// <param name="currentSequence">The current sequence.</param>
protected abstract void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence);
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (this._loadingState is not DataLoadingState.Loading && this._modalDisposable is { } modal)
{
modal.Dispose();
this._modalDisposable = null;
}
if (this._loadingState == DataLoadingState.LoadingStarted)
{
this._loadingState = DataLoadingState.Loading;
await this.InvokeAsync(() =>
{
if (this._loadingState != DataLoadingState.Loaded)
{
this._modalDisposable = this.LoadingOverlay.ShowLoadingIndicator();
this.StateHasChanged();
}
}).ConfigureAwait(false);
}
await base.OnAfterRenderAsync(firstRender).ConfigureAwait(true);
}
/// <summary>
/// Saves the changes.
/// </summary>
protected async Task SaveChangesAsync()
{
try
{
if (this._persistenceContext is { } context)
{
var success = await context.SaveChangesAsync().ConfigureAwait(true);
var text = success ? Resources.SavedChanges : Resources.NoChangesToSave;
this.ToastService.ShowSuccess(text);
}
else
{
this.ToastService.ShowError(Resources.FailedByUninitializedContext);
}
}
catch (Exception ex)
{
this.Logger?.LogError(ex, $"Error during saving {this.Id}");
var text = string.Format(Resources.UnexpectedErrorOccurred, ex.Message);
this.ToastService.ShowError(text);
}
}
/// <summary>
/// Refreshes the data by discarding changes and reloading it from the database.
/// </summary>
protected async Task RefreshAsync()
{
var isConfirmed = await this.JavaScript.InvokeAsync<bool>("window.confirm", Resources.UnsavedChangesQuestion).ConfigureAwait(true);
if (!isConfirmed)
{
return;
}
await this.EditDataSource.ForceDiscardChangesAsync().ConfigureAwait(true);
this._loadingState = DataLoadingState.LoadingStarted;
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token);
this.StateHasChanged();
}
/// <summary>
/// Gets the optional editors markup for the current type.
/// </summary>
/// <returns>The optional editors markup for the current type.</returns>
protected virtual string? GetEditorsMarkup()
{
return null;
}
/// <summary>
/// It loads the owner of the <see cref="EditDataSource" />.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
protected virtual async ValueTask LoadOwnerAsync(CancellationToken cancellationToken)
{
await this.EditDataSource.GetOwnerAsync(Guid.Empty, cancellationToken).ConfigureAwait(true);
}
private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context)
{
if (this._persistenceContext?.HasChanges is true)
{
var isConfirmed = await this.JavaScript.InvokeAsync<bool>(
"window.confirm",
Resources.UnsavedChangesQuestion)
.ConfigureAwait(true);
if (!isConfirmed)
{
context.PreventNavigation();
}
else if (this._isOwningContext)
{
this._persistenceContext.Dispose();
this._persistenceContext = null;
}
else
{
await this.EditDataSource.DiscardChangesAsync().ConfigureAwait(true);
}
}
}
private string? GetDownloadMarkup()
{
if (this.Type is not null && GenericControllerFeatureProvider.SupportedTypes.Any(t => t.Item1 == this.Type))
{
var uri = $"/download/{this.Type.Name}/{this.Type.Name}_{this.Id}.json";
return $"<p>{Resources.DownloadAsJson}: <a href=\"{uri}\" download><span class=\"oi oi-data-transfer-download\"></span></a></p>";
}
return null;
}
private Type? DetermineTypeByTypeString()
{
return AppDomain.CurrentDomain.GetAssemblies().Where(assembly => assembly.FullName?.StartsWith(nameof(MUnique)) ?? false)
.Select(assembly => assembly.GetType(this.TypeString)).FirstOrDefault(t => t != null);
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
if (this.Type is null)
{
throw new InvalidOperationException($"Only types of namespace {nameof(MUnique)} can be edited on this page.");
}
await this.LoadOwnerAsync(cancellationToken).ConfigureAwait(true);
cancellationToken.ThrowIfCancellationRequested();
if (this.EditDataSource.IsSupporting(this.Type))
{
this._isOwningContext = false;
this._persistenceContext = await this.EditDataSource.GetContextAsync(cancellationToken).ConfigureAwait(true);
}
else
{
this._isOwningContext = true;
var gameConfiguration = await this.ConfigDataSource.GetOwnerAsync(Guid.Empty, cancellationToken).ConfigureAwait(true);
this._persistenceContext = this.PersistenceContextProvider.CreateNewTypedContext(this.Type, true, gameConfiguration);
}
cancellationToken.ThrowIfCancellationRequested();
try
{
if (this.EditDataSource.IsSupporting(this.Type))
{
this._model = this.Id == default
? this.EditDataSource.GetAll(this.Type).OfType<object>().FirstOrDefault()
: this.EditDataSource.Get(this.Id);
}
else
{
this._model = this.Id == default
? (await this._persistenceContext.GetAsync(this.Type, cancellationToken).ConfigureAwait(true)).OfType<object>().FirstOrDefault()
: await this._persistenceContext.GetByIdAsync(this.Id, this.Type, cancellationToken).ConfigureAwait(true);
}
this._loadingState = this.Model is not null
? DataLoadingState.Loaded
: DataLoadingState.NotFound;
}
catch (OperationCanceledException)
{
this._loadingState = DataLoadingState.Cancelled;
throw;
}
catch (Exception ex)
{
this._loadingState = DataLoadingState.Error;
this.Logger?.LogError(ex, $"Could not load {this.Type.FullName} with {this.Id}: {ex.Message}{Environment.NewLine}{ex.StackTrace}");
await this.InvokeAsync(() => this.ModalService.ShowMessageAsync(Resources.Error, Resources.LoadingErrorCheckLog)).ConfigureAwait(false);
}
cancellationToken.ThrowIfCancellationRequested();
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
// expected when the page is getting disposed.
}
catch (TargetInvocationException ex) when (ex.InnerException is ObjectDisposedException)
{
// See ObjectDisposedException.
}
catch (ObjectDisposedException)
{
// Happens when the user navigated away (shouldn't happen with the modal loading indicator, but we check it anyway).
// It would be great to have an async api with cancellation token support in the persistence layer
// For the moment, we swallow the exception
}
catch (Exception ex)
{
this.Logger?.LogError(ex, "Unexpected error when loading data.");
}
}
}

View File

@@ -0,0 +1,78 @@
// <copyright file="EditConfig.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared.Components.Form;
using MUnique.OpenMU.Web.Shared.Components.ItemEdit;
/// <summary>
/// A generic edit page, which shows an <see cref="AutoForm{T}"/> for the given <see cref="EditBase.TypeString"/> and <see cref="EditBase.Id"/>.
/// </summary>
[Route("/edit-config/{typeString}/")]
[Route("/edit-config/{typeString}/{id:guid}")]
[Route("/edit-config/{typeString}/{id:guid}/hide-collections")]
public sealed class EditConfig : EditBase
{
private static readonly IDictionary<Type, IList<(string Caption, string Path)>> EditorPages =
new Dictionary<Type, IList<(string, string)>>
{
{ typeof(GameMapDefinition), new List<(string, string)> { (Resources.MapEditor, "/map-editor/{0}") } },
};
/// <summary>
/// Gets or sets the optional search term to pre-filter fields.
/// </summary>
[SupplyParameterFromQuery(Name = "search")]
public string? SearchTerm { get; set; }
/// <inheritdoc />
protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence)
{
var hideCollections = this.NavigationManager.Uri.EndsWith("hide-collections");
if (this.Type == typeof(Item))
{
builder.OpenComponent(++currentSequence, typeof(ItemEdit));
builder.AddAttribute(++currentSequence, nameof(ItemEdit.Item), this.Model);
builder.AddAttribute(++currentSequence, nameof(ItemEdit.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.CloseComponent();
}
else
{
builder.OpenComponent(++currentSequence, typeof(AutoForm<>).MakeGenericType(this.Type!));
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.Model), this.Model);
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.HideCollections), hideCollections);
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.SearchTerm), this.SearchTerm);
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.AddAttribute(++currentSequence, nameof(AutoForm<object>.OnRefresh), EventCallback.Factory.Create(this, this.RefreshAsync));
builder.CloseComponent();
}
}
/// <inheritdoc />
protected override string? GetEditorsMarkup()
{
StringBuilder? stringBuilder = null;
if (this.Type is not null
&& (EditorPages.TryGetValue(this.Type, out var editors)
|| (this.Type.BaseType is { } baseType && EditorPages.TryGetValue(baseType, out editors))))
{
foreach (var editor in editors)
{
var uri = string.Format(CultureInfo.InvariantCulture, editor.Path, this.Id);
stringBuilder ??= new StringBuilder();
stringBuilder.Append($@"<p><a href=""{uri}"">{editor.Caption}</a></p>");
}
}
return stringBuilder?.ToString();
}
}

View File

@@ -0,0 +1,54 @@
@page "/edit-config-grid/{typeString}"
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using Microsoft.AspNetCore.Components.QuickGrid
@if (this.Type is not null)
{
var typeCaption = this.Type.GetPluralizedTypeCaption();
<h1>@typeCaption</h1>
<PageTitle>@typeCaption</PageTitle>
<Breadcrumb Caption=@typeCaption IsFirstFromRoot="true"></Breadcrumb>
}
@if (this.ViewModels is null)
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@Resources.Loading</span>
return;
}
<div>
<QuickGrid Items="@this.ViewModels" Pagination="@_pagination" Theme="none" >
<PropertyColumn Property="@(c => c.Name)" Sortable="true">
<HeaderTemplate>
<button type="button" class="col-title" onclick="@(() => context.Grid.SortByColumnAsync(context))">
<div class="col-title-text">@context.Title</div>
<div class="sort-indicator" aria-hidden="true"></div>
</button>
<div class="col-search ms-5">
<div class="input-group">
<span class="input-group-text"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span>
<input class="form-control small" type="search" autofocus @bind="@NameFilter" @bind:event="oninput" placeholder="@Resources.Search ..."/>
</div>
</div>
</HeaderTemplate>
</PropertyColumn>
<TemplateColumn Align="Align.Start">
@{
var targetUrl = $"edit-config/{this.TypeString}/" + context.Id;
}
<a href="@(targetUrl)" class="btn btn-primary me-1 mb-1"><span class="oi oi-pencil" aria-hidden="true"></span> @Resources.Edit</a>
<button type="button" class="btn btn-secondary me-1 mb-1" @onclick="@(() => this.OnDuplicateButtonClickAsync(context))"><span class="oi oi-layers d-none d-sm-inline"></span> @Resources.Duplicate</button>
<button type="button" class="btn btn-danger me-1 mb-1" @onclick="@(() => this.OnDeleteButtonClickAsync(context))"><span class="oi oi-trash d-none d-sm-inline"></span> @Resources.Delete</button>
</TemplateColumn>
</QuickGrid>
</div>
<Paginator State="@_pagination" />
<div class="add-new-bar">
<hr />
<button type="button" class="btn btn-primary" @onclick="OnCreateButtonClickAsync"><span class="oi oi-plus"></span> @Resources.AddNew</button>
</div>

View File

@@ -0,0 +1,486 @@
// <copyright file="EditConfigGrid.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Collections;
using System.ComponentModel;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.QuickGrid;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Razor page which shows objects of the specified type in a grid.
/// </summary>
public partial class EditConfigGrid : ComponentBase, IAsyncDisposable
{
private readonly PaginationState _pagination = new() { ItemsPerPage = 20 };
private Task? _loadTask;
private CancellationTokenSource? _disposeCts;
private List<ViewModel>? _viewModels;
/// <summary>
/// Gets or sets the <see cref="Type.FullName"/> of the object which should be edited.
/// </summary>
[Parameter]
public string TypeString { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the data source.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> DataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <summary>
/// Gets or sets the persistence context provider which loads and saves the object.
/// </summary>
[Inject]
public IPersistenceContextProvider PersistenceContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the modal service.
/// </summary>
[Inject]
public IModalService ModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the creation panel service which hosts the "create new" form in a persistent side panel.
/// </summary>
[Inject]
public CreationPanelService CreationPanelService { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the logger.
/// </summary>
[Inject]
public ILogger<EditConfigGrid> Logger { get; set; } = null!;
/// <summary>
/// Gets or sets the type.
/// </summary>
private Type? Type { get; set; }
private IQueryable<ViewModel>? ViewModels
{
get
{
if (string.IsNullOrWhiteSpace(this.NameFilter))
{
return this._viewModels?.AsQueryable();
}
return this._viewModels?
.Where(vm => vm.Name.Contains(this.NameFilter.Trim(), StringComparison.InvariantCultureIgnoreCase))
.AsQueryable();
}
}
private string? NameFilter { get; set; }
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
this.CreationPanelService.ItemCreated -= this.OnItemCreatedAsync;
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = null;
try
{
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// we can ignore that ...
}
catch
{
// and we should not throw exceptions in the dispose method ...
}
}
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
this.CreationPanelService.ItemCreated += this.OnItemCreatedAsync;
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
this.NameFilter = string.Empty;
this.Type = this.DetermineTypeByTypeString();
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token), cts.Token);
await base.OnParametersSetAsync().ConfigureAwait(true);
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (this.Type is null)
{
throw new InvalidOperationException($"Only types of namespace {nameof(MUnique)} can be edited on this page.");
}
IEnumerable data;
var gameConfiguration = await this.DataSource.GetOwnerAsync(default, cancellationToken).ConfigureAwait(true);
if (this.DataSource.IsSupporting(this.Type))
{
cancellationToken.ThrowIfCancellationRequested();
data = this.DataSource.GetAll(this.Type!);
}
else
{
using var context = this.PersistenceContextProvider.CreateNewTypedContext(this.Type, true, gameConfiguration);
data = await context.GetAsync(this.Type, cancellationToken).ConfigureAwait(false);
}
this._viewModels = data.OfType<object>()
.Select(o => new ViewModel(o))
.OrderBy(o => o.Name)
.ToList();
await this.InvokeAsync(async () =>
{
await this._pagination.SetCurrentPageIndexAsync(0).ConfigureAwait(true);
this.StateHasChanged();
}).ConfigureAwait(false);
}
private Type? DetermineTypeByTypeString()
{
return AppDomain.CurrentDomain.GetAssemblies().Where(assembly => assembly.FullName?.StartsWith(nameof(MUnique)) ?? false)
.Select(assembly => assembly.GetType(this.TypeString)).FirstOrDefault(t => t != null);
}
private async Task OnDeleteButtonClickAsync(ViewModel viewModel)
{
try
{
var dialogResult = await this.ModalService.ShowQuestionAsync("Are you sure?", $"You're about to delete '{viewModel.Name}. Are you sure?").ConfigureAwait(true);
if (!dialogResult)
{
return;
}
var cancellationToken = this._disposeCts?.Token ?? default;
var gameConfiguration = await this.DataSource.GetOwnerAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
using var deleteContext = this.PersistenceContextProvider.CreateNewTypedContext(this.Type!, false, gameConfiguration);
var toDelete = await deleteContext.GetByIdAsync(viewModel.Id, this.Type!, cancellationToken).ConfigureAwait(false);
if (toDelete is null)
{
this.ToastService.ShowError(string.Format(Resources.CouldNotFindToDelete, viewModel.Name));
return;
}
await deleteContext.DeleteAsync(toDelete).ConfigureAwait(false);
await deleteContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await this.DataSource.ForceDiscardChangesAsync().ConfigureAwait(false);
this.ToastService.ShowSuccess($"Deleted '{viewModel.Name}' successfully.");
this._viewModels = null;
this._loadTask = Task.Run(() => this.LoadDataAsync(cancellationToken), cancellationToken);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Couldn't delete {viewModelName}, probably because it's referenced by another object.", viewModel.Name);
this.ToastService.ShowError(Resources.DeleteFailedReferenced);
}
}
private async Task OnCreateButtonClickAsync()
{
var cancellationToken = this._disposeCts?.Token ?? default;
var gameConfiguration = await this.DataSource.GetOwnerAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var creationContext = this.PersistenceContextProvider.CreateNewTypedContext(this.Type!, true, gameConfiguration);
try
{
var newObject = creationContext.CreateNew(this.Type!);
var session = new CreationSession
{
Title = $"Create {this.Type!.GetTypeCaption()}",
Item = newObject,
ItemType = this.Type!,
Context = creationContext,
OwnsContext = true,
SaveAsync = async () =>
{
await creationContext.SaveChangesAsync().ConfigureAwait(false);
await this.DataSource.ForceDiscardChangesAsync().ConfigureAwait(false);
this.ToastService.ShowSuccess(Resources.CreatedSuccessfully);
},
};
var started = await this.CreationPanelService.BeginAsync(session).ConfigureAwait(false);
if (!started)
{
creationContext.Dispose();
}
}
catch
{
creationContext.Dispose();
throw;
}
}
private Task OnItemCreatedAsync(Type createdType)
{
if (createdType != this.Type)
{
return Task.CompletedTask;
}
return this.InvokeAsync(() =>
{
var cancellationToken = this._disposeCts?.Token ?? default;
this._viewModels = null;
this.StateHasChanged();
this._loadTask = Task.Run(() => this.LoadDataAsync(cancellationToken), cancellationToken);
});
}
private async Task OnDuplicateButtonClickAsync(ViewModel viewModel)
{
try
{
var cancellationToken = this._disposeCts?.Token ?? default;
var gameConfiguration = await this.DataSource.GetOwnerAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
var context = this.PersistenceContextProvider.CreateNewTypedContext(this.Type!, true, gameConfiguration);
try
{
var original = await context.GetByIdAsync(viewModel.Id, this.Type!, cancellationToken).ConfigureAwait(false);
if (original is null)
{
this.ToastService.ShowError(string.Format(Resources.CouldNotFindToDuplicate, viewModel.Name));
context.Dispose();
return;
}
var newObject = await this.DuplicateObjectAsync(original, gameConfiguration, context, viewModel, cancellationToken).ConfigureAwait(false);
if (newObject is null)
{
context.Dispose();
return;
}
var duplicatedName = viewModel.Name;
var session = new CreationSession
{
Title = $"Duplicate '{duplicatedName}'",
Item = newObject,
ItemType = this.Type!,
Context = context,
OwnsContext = true,
SaveAsync = async () =>
{
await context.SaveChangesAsync().ConfigureAwait(false);
await this.DataSource.ForceDiscardChangesAsync().ConfigureAwait(false);
this.ToastService.ShowSuccess(string.Format(Resources.DuplicatedSuccessfully, duplicatedName));
},
};
var started = await this.CreationPanelService.BeginAsync(session).ConfigureAwait(false);
if (!started)
{
context.Dispose();
}
}
catch
{
context.Dispose();
throw;
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Error duplicating {viewModelName}.", viewModel.Name);
this.ToastService.ShowError(string.Format(Resources.ErrorDuplicating, viewModel.Name, ex.Message));
}
}
private async Task<object?> DuplicateObjectAsync(object original, GameConfiguration gameConfiguration, IContext context, ViewModel viewModel, CancellationToken cancellationToken)
{
var cloned = this.CreateClone(original, gameConfiguration, viewModel);
if (cloned is null)
{
return null;
}
var newObject = context.CreateNew(this.Type!);
var properties = this.Type!.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.Name != "Id");
foreach (var prop in properties)
{
try
{
await this.CopyPropertyAsync(prop, cloned, newObject, context, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
this.Logger.LogDebug(ex, "Skipping property {propName} which can't be copied.", prop.Name);
}
}
return newObject;
}
private object? CreateClone(object original, GameConfiguration gameConfiguration, ViewModel viewModel)
{
var cloneMethods = this.Type!.GetMethods(BindingFlags.Public | BindingFlags.Instance).Where(m => m.Name == "Clone").ToList();
if (cloneMethods.Count == 0)
{
this.ToastService.ShowError(string.Format(Resources.TypeDoesNotSupportCloning, this.Type.Name));
return null;
}
var cloneMethod = cloneMethods.FirstOrDefault(m =>
m.GetParameters().Length == 1 &&
m.GetParameters()[0].ParameterType == typeof(GameConfiguration));
if (cloneMethod is null || !this.Type.IsAssignableFrom(cloneMethod.ReturnType))
{
this.ToastService.ShowError($"Type '{this.Type.Name}' must have a Clone method that takes '{nameof(GameConfiguration)}' and returns '{this.Type.Name}'.");
return null;
}
var cloned = cloneMethod.Invoke(original, new object[] { gameConfiguration });
if (cloned is null || !this.Type.IsAssignableFrom(cloned.GetType()))
{
this.ToastService.ShowError(string.Format(Resources.FailedToClone, viewModel.Name));
return null;
}
return cloned;
}
private async Task CopyPropertyAsync(PropertyInfo prop, object source, object target, IContext context, CancellationToken cancellationToken)
{
var sourceValue = prop.GetValue(source);
var targetValue = prop.GetValue(target);
if (sourceValue is IEnumerable sourceCollection && sourceValue is not string)
{
await this.CopyCollectionPropertyAsync(prop, sourceCollection, targetValue, context, cancellationToken).ConfigureAwait(false);
return;
}
if (prop.CanWrite && prop.GetSetMethod(false) is not null)
{
await this.CopySinglePropertyAsync(prop, sourceValue, target, context, cancellationToken).ConfigureAwait(false);
}
}
private async Task CopyCollectionPropertyAsync(PropertyInfo prop, IEnumerable sourceCollection, object? targetValue, IContext context, CancellationToken cancellationToken)
{
var addMethod = targetValue?.GetType().GetMethod("Add");
if (addMethod is null)
{
return;
}
var clearMethod = targetValue!.GetType().GetMethod("Clear");
clearMethod?.Invoke(targetValue, null);
var itemType = prop.PropertyType.IsGenericType
? prop.PropertyType.GetGenericArguments().FirstOrDefault()
: null;
foreach (var item in sourceCollection)
{
var itemToSet = item;
if (itemToSet is IIdentifiable identifiableItem && identifiableItem.Id != Guid.Empty && itemType is not null)
{
var trackedItem = await context.GetByIdAsync(identifiableItem.Id, itemType, cancellationToken).ConfigureAwait(false);
if (trackedItem is not null)
{
itemToSet = trackedItem;
}
}
addMethod.Invoke(targetValue, new[] { itemToSet });
}
}
private async Task CopySinglePropertyAsync(PropertyInfo prop, object? sourceValue, object target, IContext context, CancellationToken cancellationToken)
{
var sourceValueToSet = sourceValue;
if (sourceValueToSet is IIdentifiable identifiable && identifiable.Id != Guid.Empty)
{
var trackedItem = await context.GetByIdAsync(identifiable.Id, prop.PropertyType, cancellationToken).ConfigureAwait(false);
if (trackedItem is not null)
{
sourceValueToSet = trackedItem;
}
}
prop.SetValue(target, sourceValueToSet);
}
/// <summary>
/// The view model for the grid.
/// We use this instead of the objects, because it makes the code simpler.
/// Creating generic components is a bit complicated when you don't
/// have the type as generic type parameter.
/// </summary>
public class ViewModel
{
/// <summary>
/// Initializes a new instance of the <see cref="ViewModel"/> class.
/// </summary>
/// <param name="parent">The parent.</param>
public ViewModel(object parent)
{
this.Parent = parent;
this.Id = parent.GetId();
this.Name = parent.GetName();
}
/// <summary>
/// Gets the parent object, which is displayed.
/// </summary>
[Browsable(false)]
public object Parent { get; }
/// <summary>
/// Gets or sets the identifier of the object.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the object.
/// </summary>
public string Name { get; set; }
}
}

View File

@@ -0,0 +1,39 @@
.col-title {
min-width: 0px;
display: flex;
align-items: center;
gap: 0.25rem;
flex-grow: 1;
padding: 0.25rem 0.5rem;
border: none;
border-radius: var(--bs-border-radius);
background: none;
cursor: pointer;
color: inherit;
transition: background-color 0.15s;
}
.col-title:hover {
background-color: rgba(128, 128, 128, 0.15);
}
.col-title-text {
font-weight: bold;
}
.col-search {
width: 50%;
}
.add-new-bar {
position: sticky;
bottom: 0;
background-color: var(--omu-surface-2);
padding: 0.5rem 1.5rem;
margin: 1rem -1.5rem -1rem -1.5rem;
border-top: var(--bs-border-width) solid var(--omu-border);
}
.add-new-bar hr {
display: none;
}

View File

@@ -0,0 +1,29 @@
// <copyright file="EditConnectionServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Web.AdminPanel.Components.ConnectServer;
/// <summary>
/// Edit page for the <see cref="ConnectServerConfiguration"/>.
/// </summary>
[Route("/edit-connectionServer/{id:guid}")]
public sealed class EditConnectionServer : EditBase
{
/// <inheritdoc />
protected override Type? Type => typeof(ConnectServerDefinition);
/// <inheritdoc />
protected override void AddFormToRenderTree(RenderTreeBuilder builder, ref int currentSequence)
{
builder.OpenComponent<ConnectServerConfiguration>(++currentSequence);
builder.AddAttribute(++currentSequence, nameof(ConnectServerConfiguration.Model), this.Model);
builder.AddAttribute(++currentSequence, nameof(ConnectServerConfiguration.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.CloseComponent();
}
}

View File

@@ -0,0 +1,288 @@
// <copyright file="EditMap.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.Reflection;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components;
using MUnique.OpenMU.Web.Shared.Components.MapEditor;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// A page, which shows an <see cref="MapEditor"/> for all <see cref="GameConfiguration.Maps"/>.
/// </summary>
[Route("/map-editor")]
[Route("/map-editor/{SelectedMapId:guid}")]
public sealed class EditMap : ComponentBase, IDisposable
{
private List<GameMapDefinition>? _maps;
private CancellationTokenSource? _disposeCts;
private IContext? _context;
private IDisposable? _navigationLockDisposable;
/// <summary>
/// Gets or sets the selected map identifier.
/// </summary>
[Parameter]
public Guid SelectedMapId { get; set; }
/// <summary>
/// Gets or sets the modal service.
/// </summary>
[Inject]
private IModalService ModalService { get; set; } = null!;
/// <summary>
/// Gets or sets the loading overlay service.
/// </summary>
[Inject]
private LoadingOverlayService LoadingOverlay { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
private IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the game configuration source.
/// </summary>
[Inject]
private IDataSource<GameConfiguration> GameConfigurationSource { get; set; } = null!;
/// <summary>
/// Gets or sets the logger.
/// </summary>
[Inject]
private ILogger<EditMap> Logger { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
private NavigationManager NavigationManager { get; set; } = null!;
/// <summary>
/// Gets or sets the JavaScript runtime.
/// </summary>
[Inject]
private IJSRuntime JavaScript { get; set; } = null!;
/// <inheritdoc />
public void Dispose()
{
this._disposeCts?.Cancel();
this._disposeCts?.Dispose();
this._disposeCts = null;
this._navigationLockDisposable?.Dispose();
this._navigationLockDisposable = null;
}
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (this._maps is null)
{
return;
}
builder.OpenComponent<Breadcrumb>(0);
builder.AddAttribute(1, nameof(Breadcrumb.Caption), Resources.MapEditor);
builder.CloseComponent();
builder.OpenComponent<CascadingValue<IContext>>(10);
builder.AddAttribute(11, nameof(CascadingValue<IContext>.Value), this._context);
builder.AddAttribute(12, nameof(CascadingValue<IContext>.IsFixed), false);
builder.AddAttribute(13, nameof(CascadingValue<IContext>.ChildContent), (RenderFragment)this.BuildMapEditorFragment);
builder.CloseComponent();
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = new CancellationTokenSource();
this._context = await this.GameConfigurationSource
.GetContextAsync(this._disposeCts.Token)
.ConfigureAwait(false);
await base.OnParametersSetAsync().ConfigureAwait(false);
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender).ConfigureAwait(false);
if (this._maps is null)
{
this._disposeCts ??= new CancellationTokenSource();
var cts = this._disposeCts.Token;
_ = Task.Run(() => this.LoadDataAsync(cts), cts);
}
}
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync);
return base.OnInitializedAsync();
}
private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context)
{
if (!await this.AllowChangeAsync().ConfigureAwait(false))
{
context.PreventNavigation();
}
}
private async Task OnSelectedMapChangingAsync(MapChangingArgs eventArgs)
{
eventArgs.Cancel = !await this.AllowChangeAsync().ConfigureAwait(true);
if (!eventArgs.Cancel)
{
this.SelectedMapId = eventArgs.NextMap;
}
}
private async ValueTask<bool> AllowChangeAsync()
{
var cancellationToken = this._disposeCts?.Token ?? default;
var persistenceContext = await this.GameConfigurationSource
.GetContextAsync(cancellationToken)
.ConfigureAwait(true);
if (persistenceContext?.HasChanges is not true)
{
return true;
}
var isConfirmed = await this.JavaScript
.InvokeAsync<bool>("window.confirm", cancellationToken, Resources.UnsavedChangesQuestion)
.ConfigureAwait(true);
if (!isConfirmed)
{
return false;
}
await this.GameConfigurationSource.DiscardChangesAsync().ConfigureAwait(true);
IDisposable? loadingOverlay = null;
var showOverlayTask = this.InvokeAsync(() => loadingOverlay = this.LoadingOverlay.ShowLoadingIndicator());
try
{
this._context = await this.GameConfigurationSource
.GetContextAsync(cancellationToken)
.ConfigureAwait(true);
var gameConfig = await this.GameConfigurationSource
.GetOwnerAsync(Guid.Empty, cancellationToken)
.ConfigureAwait(false);
this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList();
}
finally
{
await showOverlayTask.ConfigureAwait(false);
loadingOverlay?.Dispose();
}
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
return true;
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
IDisposable? modal = null;
var showModalTask = this.InvokeAsync(() => modal = this.LoadingOverlay.ShowLoadingIndicator());
try
{
if (!cancellationToken.IsCancellationRequested)
{
var gameConfig = await this.GameConfigurationSource
.GetOwnerAsync(Guid.Empty, cancellationToken)
.ConfigureAwait(false);
try
{
this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList();
}
catch (Exception ex)
{
this.Logger.LogError(
ex,
"Could not load game maps: {Message}{NewLine}{StackTrace}",
ex.Message,
Environment.NewLine,
ex.StackTrace);
await this.ModalService
.ShowMessageAsync(Resources.Error, Resources.CouldNotLoadMapDataCheckTheLogs)
.ConfigureAwait(false);
}
await showModalTask.ConfigureAwait(false);
modal?.Dispose();
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false);
}
}
catch (TargetInvocationException ex) when (ex.InnerException is ObjectDisposedException)
{
// See ObjectDisposedException.
}
catch (ObjectDisposedException)
{
// Happens when the user navigated away. The persistence layer does not
// yet have a cancellation token based async API so we swallow this.
}
}
private async Task SaveChangesAsync()
{
try
{
var context = await this.GameConfigurationSource.GetContextAsync().ConfigureAwait(true);
var success = await context.SaveChangesAsync().ConfigureAwait(true);
var text = success ? Resources.SavedChanges : Resources.NoChangesToSave;
this.ToastService.ShowSuccess(text);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Error during saving");
this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorCheckLogs, ex.Message));
}
}
private void BuildMapEditorFragment(RenderTreeBuilder builder)
{
builder.OpenComponent<MapEditor>(15);
builder.AddAttribute(16, nameof(MapEditor.Maps), this._maps);
builder.AddAttribute(17, nameof(MapEditor.SelectedMapId), this.SelectedMapId);
builder.AddAttribute(18, nameof(MapEditor.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync));
builder.AddAttribute(
19,
nameof(MapEditor.SelectedMapChanging),
EventCallback.Factory.Create<MapChangingArgs>(this, this.OnSelectedMapChangingAsync));
builder.CloseComponent();
}
}

View File

@@ -0,0 +1,14 @@
@page "/error"
@using MUnique.OpenMU.Web.AdminPanel.Properties
<PageTitle>@Resources.Error</PageTitle>
<h1 class="text-danger">@Resources.Error.</h1>
<h2 class="text-danger">@Resources.AnErrorOccurredWhileProcessingYourRequest.</h2>
<h3>@Resources.DevelopmentMode</h3>
<p>
@Resources.SwappingToDevForMoreInformation
</p>
<p>
@Resources.DevelopmentEnvironmentWarning
</p>

View File

@@ -0,0 +1,57 @@
@page "/gameServer/{gameServerId:int}/"
@implements IDisposable
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Map.Components
@using MUnique.OpenMU.Web.Map
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.GameLogic
@if (this._gameServer is not null)
{
<PageTitle>OpenMU: @Resources.GameServer @this._gameServer.Id</PageTitle>
<Breadcrumb Caption=@($"{Resources.GameServer} {this._gameServer.Id}") />
<CascadingValue Value="@_liveMapRoute" Name="LiveMapRoute">
<CascadingValue Value="@_gameServer">
<MapCards GameServer="@_gameServer"></MapCards>
</CascadingValue>
</CascadingValue>
}
@code {
private string _liveMapRoute = string.Empty;
private IObservableGameServer? _gameServer;
/// <summary>
/// Gets or sets the server id on which the map is hosted.
/// </summary>
[Inject]
public IList<IManageableServer> Servers { get; set; } = null!;
/// <summary>
/// Gets or sets the 0-based game server id/index.
/// </summary>
[Parameter]
public int GameServerId { get; set; }
/// <inheritdoc />
public void Dispose()
{
(this._gameServer as IDisposable)?.Dispose();
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
var gameServer = this.Servers.OfType<IGameServer>().First(gs => gs.Id == this.GameServerId);
var context = (gameServer as IGameServerContextProvider)?.Context ?? throw new InvalidOperationException("This map page just works in an All-In-One deployment.");
var adapter = new ObservableGameServerAdapter(context);
await adapter.InitializeAsync();
this._gameServer = adapter;
this._liveMapRoute = $"gameServer/{this.GameServerId}/map/";
await base.OnInitializedAsync();
}
}

View File

@@ -0,0 +1,9 @@
@page "/"
@using MUnique.OpenMU.Web.AdminPanel.Properties
<h1>@Resources.OpenMUAdminPanel</h1>
<PageTitle>@Resources.OpenMUAdminPanel</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="Home"/>
@Resources.WelcomeMessage

View File

@@ -0,0 +1,57 @@
@page "/logfiles"
@using System.IO
@using MUnique.OpenMU.Web.AdminPanel.Properties
<PageTitle>OpenMU: @Resources.LogFiles</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.LogFiles"/>
<div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>@Resources.FileName</th>
<th>@Resources.LastUpdate</th>
<th>@Resources.Size</th>
</tr>
</thead>
<tbody>
@foreach (var entry in this._files.OrderByDescending(f => f.LastWriteTime))
{
<tr>
<td>
<a href="logs/@entry.Name">@entry.Name</a>
</td>
<td>@entry.LastWriteTime</td>
<td>@FormatFileSize(entry.Length)</td>
</tr>
}
</tbody>
</table>
</div>
@code {
private readonly List<FileInfo> _files = new ();
/// <summary>
/// Initializes a new instance of class <see cref="LogFiles"/>.
/// </summary>
public LogFiles()
{
var files = Directory.GetFiles(Path.Combine(Directory.GetCurrentDirectory(), "logs"));
foreach (var filePath in files)
{
this._files.Add(new FileInfo(filePath));
}
}
private string FormatFileSize(long size)
{
return size switch
{
(< 1024 << 10) => $"{Math.Round(size / 1024D, 2)} KiB",
(< 1024 << 20) => $"{Math.Round(size * 1D / (1024 << 10), 2)} MiB",
(< 1024L << 30) => $"{Math.Round(size * 1D / (1024L << 20), 2)} GiB",
_ => $"{size} bytes"
};
}
}

View File

@@ -0,0 +1,54 @@
@page "/logged-in"
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.DataModel.Entities
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Shared.Services
@inject LoggedInAccountService AccountService;
@inject OfflineAccountService OfflineService;
<PageTitle>OpenMU: @Resources.OnlineAccounts</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.OnlineAccounts"/>
<h1>@Resources.OnlineAccounts</h1>
<div class="w-100">
<DataTable TItem=@LoggedInAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.ServerID</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@item.Server</td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.AccountService.SetAccountOfflineAsync(item)">
<span class="oi oi-bolt" aria-hidden="true" /> @Resources.Disconnect
</button>
</td>
</ItemTemplate>
</DataTable>
</div>
<h2>@Resources.ActiveOfflinePlayer</h2>
<div class="w-100">
<DataTable TItem=@OfflineAccount>
<TableHeader>
<th class="col-3">@typeof(Account).GetPropertyCaption(nameof(Account.LoginName))</th>
<th class="col-2">@Resources.ServerID</th>
<th class="col-3">@Resources.StartedAt</th>
<th class="col-2">@Resources.Action</th>
</TableHeader>
<ItemTemplate Context="item">
<td>@item.LoginName</td>
<td>@item.ServerId</td>
<td>@item.StartedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
<td>
<button class="btn btn-warning btn-sm" type="button" @onclick="() => this.OfflineService.StopOfflinePlayerAsync(item)">
<span class="oi oi-media-stop" aria-hidden="true" /> @Resources.Stop
</button>
</td>
</ItemTemplate>
</DataTable>
</div>

View File

@@ -0,0 +1,194 @@
@page "/gameServer/{gameServerId:int}/map/{mapId:guid}"
@implements IDisposable
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Map.Components
@using MUnique.OpenMU.Web.Map
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.GameLogic
@using System.Threading
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.JSInterop
<PageTitle>OpenMU: @Resources.LiveMap</PageTitle>
<Breadcrumb Caption="@Resources.LiveMap" />
@if (this._gameServer is not null)
{
<div>
<NavLink href="" Match="NavLinkMatch.All">
<span>@Resources.All</span>
</NavLink>
<span> / @this._map?.MapName
@if (this._followedPlayerName is not null)
{
<span class="ms-2 badge bg-info">@string.Format("Following: {0}", this._followedPlayerName)</span>
}
</span>
</div>
<CascadingValue Value="@_gameServer">
<Map Server="@_gameServer" MapId="@MapId" FollowedPlayerName="@_followedPlayerName" OnFollowPlayer="@HandleFollowPlayer"></Map>
</CascadingValue>
}
@code {
internal const string LiveMapRoute = "map/";
private IGameMapInfo? _map;
private IObservableGameServer? _gameServer;
private IGameServerContext? _gameContext;
private string? _followedPlayerName;
private Timer? _followTimer;
/// <summary>
/// Gets or sets the server id on which the map is hosted.
/// </summary>
[Inject]
public IList<IManageableServer> Servers { get; set; } = null!;
/// <summary>
/// Gets or sets the map id.
/// </summary>
[Parameter]
public Guid MapId { get; set; }
/// <summary>
/// Gets or sets the game server id/index.
/// </summary>
[Parameter]
public int GameServerId { get; set; }
[Inject]
private NavigationManager NavigationManager { get; set; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; set; } = null!;
/// <inheritdoc />
public void Dispose()
{
(this._gameServer as IDisposable)?.Dispose();
this._followTimer?.Dispose();
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
var gameServer = this.Servers.OfType<IGameServer>().First(gs => gs.Id == this.GameServerId);
var context = (gameServer as IGameServerContextProvider)?.Context ?? throw new InvalidOperationException("This map page just works in a All-In-One deployment.");
var adapter = new ObservableGameServerAdapter(context);
await adapter.InitializeAsync();
this._gameContext = context;
this._gameServer = adapter;
this._map = this._gameServer.Maps.First(m => m.Id == this.MapId);
var uri = new Uri(this.NavigationManager.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("follow", out var followParam))
{
var player = this._gameContext?.GetPlayerByCharacterName(followParam.ToString());
if (player?.CurrentMap?.Id == this.MapId)
{
this._followedPlayerName = followParam.ToString();
_ = this.TryHighlightFollowedPlayerAsync();
this._followTimer = new Timer(this.CheckFollowedPlayer, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
}
await base.OnInitializedAsync();
}
private void HandleFollowPlayer(string? playerName)
{
if (playerName is null || playerName == this._followedPlayerName)
{
this.StopFollowing();
}
else
{
this.StartFollowing(playerName);
}
}
private void StartFollowing(string playerName)
{
this._followedPlayerName = playerName;
this._followTimer?.Dispose();
_ = this.TryHighlightFollowedPlayerAsync();
this._followTimer = new Timer(this.CheckFollowedPlayer, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
private void StopFollowing()
{
this._followedPlayerName = null;
this._followTimer?.Dispose();
this._followTimer = null;
}
private async Task TryHighlightFollowedPlayerAsync()
{
if (this._followedPlayerName is null)
{
return;
}
try
{
await this.JsRuntime.InvokeVoidAsync("HighlightFollowedPlayer", this.GameServerId, this.MapId, this._followedPlayerName).ConfigureAwait(false);
}
catch
{
// player not yet in the Three.js scene - will retry on next timer tick
}
}
private void CheckFollowedPlayer(object? state)
{
_ = this.CheckFollowedPlayerAsync();
}
private async Task CheckFollowedPlayerAsync()
{
try
{
if (this._followedPlayerName is null)
{
return;
}
var player = this._gameContext?.GetPlayerByCharacterName(this._followedPlayerName);
if (player is null)
{
return;
}
if (player.CurrentMap is null)
{
return;
}
if (player.CurrentMap.Id != this.MapId)
{
this._followTimer?.Dispose();
this._followTimer = null;
var url = $"/gameServer/{this.GameServerId}/map/{player.CurrentMap.Id:N}?follow={Uri.EscapeDataString(this._followedPlayerName)}";
await this.InvokeAsync(() => this.NavigationManager.NavigateTo(url, forceLoad: true));
}
else
{
await this.TryHighlightFollowedPlayerAsync();
}
}
catch
{
// Prevent background thread exceptions from crashing the application
}
}
}

View File

@@ -0,0 +1,50 @@
@page "/merchants"
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using Microsoft.AspNetCore.Components.QuickGrid;
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.Persistence
<PageTitle>OpenMU: @Resources.Merchants</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption=@Resources.Merchants/>
<h1>@Resources.Merchants</h1>
@if (this.ViewModels is null)
{
<span class="spinner-border" role="status" aria-hidden="true"></span>
<span class="visually-hidden">@Resources.Loading</span>
return;
}
@if (this._selectedMerchant is null)
{
<div>
<QuickGrid Items="@this.ViewModels" Pagination="@_merchantPagination" Theme="none">
<PropertyColumn Title="Name" Property="@(c => c.Name)" Sortable="true">
</PropertyColumn>
<TemplateColumn Align="Align.Start">
<button type="button" class="btn btn-primary" @onclick="@(() => this.OnMerchantEditClickAsync(context))"><span class="oi oi-pencil" aria-hidden="true"></span> @Resources.Edit</button>
</TemplateColumn>
</QuickGrid>
</div>
<Paginator State="@_merchantPagination"/>
}
else
{
<div class="d-flex flex-row">
<button type="button" class="btn btn-secondary me-3" @onclick="this.OnBackButtonClickAsync">&lt; @Resources.Back</button>
<h2>@((LocalizedString?)this._selectedMerchant.Merchant.Designation)</h2>
</div>
<hr/>
<CascadingValue TValue="IContext" Value="this._persistenceContext">
<EditForm Model="this._selectedMerchant.Merchant" OnValidSubmit="this.OnSaveButtonClickAsync">
<ItemStorageField @bind-Value="this._selectedMerchant.Merchant.MerchantStore" HideLabel="true"/>
<hr />
<button type="submit" class="btn btn-primary me-1">@Resources.SaveChanges</button>
<button type="button" class="btn btn-secondary" @onclick="this.OnCancelButtonClickAsync">@Resources.DiscardChanges</button>
</EditForm>
</CascadingValue>
}

View File

@@ -0,0 +1,258 @@
// <copyright file="Merchants.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using System.ComponentModel;
using System.Threading;
using Blazored.Toast.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.QuickGrid;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Web.AdminPanel.Properties;
/// <summary>
/// Razor page which shows objects of the specified type in a grid.
/// </summary>
public partial class Merchants : ComponentBase, IAsyncDisposable
{
private readonly PaginationState _merchantPagination = new() { ItemsPerPage = 20 };
private readonly PaginationState _itemPagination = new() { ItemsPerPage = 20 };
private Task? _loadTask;
private CancellationTokenSource? _disposeCts;
private List<MerchantStorageViewModel>? _viewModels;
private MerchantStorageViewModel? _selectedMerchant;
private IContext? _persistenceContext;
private IDisposable? _navigationLockDisposable;
/// <summary>
/// Gets or sets the data source.
/// </summary>
[Inject]
public IDataSource<GameConfiguration> DataSource { get; set; } = null!;
/// <summary>
/// Gets or sets the context provider.
/// </summary>
[Inject]
public IPersistenceContextProvider ContextProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// Gets or sets the navigation manager.
/// </summary>
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
/// <summary>
/// Gets or sets the java script runtime.
/// </summary>
[Inject]
public IJSRuntime JavaScript { get; set; } = null!;
/// <summary>
/// Gets or sets the logger.
/// </summary>
[Inject]
public ILogger<Merchants> Logger { get; set; } = null!;
private IQueryable<MerchantStorageViewModel>? ViewModels => this._viewModels?.AsQueryable();
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
this._navigationLockDisposable?.Dispose();
this._navigationLockDisposable = null;
await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false);
this._disposeCts?.Dispose();
this._disposeCts = null;
try
{
await (this._loadTask ?? Task.CompletedTask).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// we can ignore that ...
}
catch
{
// and we should not throw exceptions in the dispose method ...
}
}
/// <inheritdoc />
protected override Task OnInitializedAsync()
{
this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync);
return base.OnInitializedAsync();
}
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
var cts = new CancellationTokenSource();
this._disposeCts = cts;
this._loadTask = Task.Run(() => this.LoadDataAsync(cts.Token));
await base.OnParametersSetAsync().ConfigureAwait(true);
}
private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context)
{
if (this._persistenceContext?.HasChanges is not true)
{
return;
}
var isConfirmed = await this.JavaScript.InvokeAsync<bool>(
"window.confirm",
Resources.UnsavedChangesQuestion)
.ConfigureAwait(true);
if (!isConfirmed)
{
context.PreventNavigation();
}
else
{
await this.DataSource.DiscardChangesAsync().ConfigureAwait(true);
}
}
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
this._persistenceContext = await this.DataSource.GetContextAsync(cancellationToken).ConfigureAwait(true);
await this.DataSource.GetOwnerAsync(cancellationToken: cancellationToken).ConfigureAwait(true);
var data = this.DataSource.GetAll<MonsterDefinition>()
.Where(m => m is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore: { } });
this._viewModels = data
.Select(o => new MerchantStorageViewModel(o))
.OrderBy(o => o.Name)
.ToList();
await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
// Expected when navigating away - ignore
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Error loading merchant data");
}
}
private async Task OnMerchantEditClickAsync(MerchantStorageViewModel context)
{
this._selectedMerchant = context;
await this.InvokeAsync(async () => await this._itemPagination.SetCurrentPageIndexAsync(0).ConfigureAwait(true)).ConfigureAwait(true);
}
private async Task OnSaveButtonClickAsync()
{
try
{
if (this._persistenceContext is { } context)
{
var success = await context.SaveChangesAsync().ConfigureAwait(true);
var text = success ? Resources.SavedChanges : Resources.NoChangesToSave;
this.ToastService.ShowSuccess(text);
}
else
{
this.ToastService.ShowError(Resources.FailedByUninitializedContext);
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, $"An unexpected error occurred on save: {ex.Message}");
this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorOccurred, ex.Message));
}
}
private async Task OnCancelButtonClickAsync()
{
if (this._persistenceContext?.HasChanges is true)
{
await this.DataSource.DiscardChangesAsync().ConfigureAwait(true);
await this.LoadDataAsync(this._disposeCts?.Token ?? default).ConfigureAwait(true);
}
}
private async Task OnBackButtonClickAsync()
{
if (this._persistenceContext?.HasChanges is true)
{
var isConfirmed = await this.JavaScript.InvokeAsync<bool>(
"window.confirm",
Resources.UnsavedChangesQuestion)
.ConfigureAwait(true);
if (!isConfirmed)
{
return;
}
await this.OnCancelButtonClickAsync().ConfigureAwait(true);
}
this._selectedMerchant = null;
}
/// <summary>
/// The view model for a merchant store.
/// </summary>
public class MerchantStorageViewModel
{
/// <summary>
/// Initializes a new instance of the <see cref="MerchantStorageViewModel"/> class.
/// </summary>
/// <param name="merchant">The merchant.</param>
public MerchantStorageViewModel(MonsterDefinition merchant)
{
this.Merchant = merchant;
this.Id = merchant.GetId();
}
/// <summary>
/// Gets the identifier.
/// </summary>
[Browsable(false)]
public Guid Id { get; }
/// <summary>
/// Gets the merchant definition.
/// </summary>
[Browsable(false)]
public MonsterDefinition Merchant { get; }
/// <summary>
/// Gets the name of the merchant.
/// </summary>
[Browsable(false)]
public string Name => this.Merchant.Designation;
/// <summary>
/// Gets the items of the merchant.
/// </summary>
public ICollection<Item> Items => this.Merchant.MerchantStore!.Items;
}
}

View File

@@ -0,0 +1,6 @@
@page "/not-found"
@using MUnique.OpenMU.Web.AdminPanel.Components.Layout
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,54 @@
@page "/plugins"
@using MUnique.OpenMU.Web.AdminPanel.Properties
@using MUnique.OpenMU.Web.Shared.Models
<PageTitle>@Resources.Plugins</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.Plugins"/>
<h1>@Resources.Plugins</h1>
<div>
<DataTable TItem=@PlugInConfigurationViewItem>
<TableHeader>
<th class="col-1">@Resources.ExtensionPoint</th>
<th class="col-1">@Resources.PluginName</th>
<th class="col-1">@Resources.PluginType</th>
<th class="col-1">@Resources.Action</th>
</TableHeader>
<FilterHeader>
<th><PlugInExtensionPointSelection SelectedPointId="@this.PlugInController.PointFilter" ExtensionPoints="@this.PlugInController.ExtensionPoints" OnSelectionChanged="@this.OnPlugInPointSelected" /></th>
<th><div class="input-group"><span class="input-group-text"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span><input type="search" class="form-control" placeholder="Search" @bind="@this.PlugInController.NameFilter" @bind:event="oninput"/></div></th>
<th><div class="input-group"><span class="input-group-text"><span class="oi oi-magnifying-glass" aria-hidden="true"></span></span><input type="search" class="form-control" placeholder="Search" @bind="@this.PlugInController.TypeFilter" @bind:event="oninput"/></div></th>
<th></th>
</FilterHeader>
<ItemTemplate Context="item">
<td title=@item.PlugInPointDescription>@item.PlugInPointName</td>
<td title=@item.PlugInDescription>
<MarkedText Marked=@this.PlugInController.NameFilter Text=@item.PlugInName />
</td>
<td title=@item.TypeId>
<MarkedText Marked=@this.PlugInController.TypeFilter Text=@item.TypeName />
</td>
<td class="text-nowrap">
@if (item.IsActive)
{
<button class="btn btn-warning me-1" @onclick="async () => await this.PlugInController.DeactivateAsync(item)">@Resources.Deactivate</button>
}
else
{
<button class="btn btn-success me-1" @onclick="async () => await this.PlugInController.ActivateAsync(item)">@Resources.Activate</button>
}
@if (item.ConfigurationType is { })
{
<button class="btn btn-secondary" @onclick="() => this.PlugInController.ShowPlugInConfigAsync(item)"><span class="oi oi-cog" aria-hidden="true"></span></button>
}
</td>
</ItemTemplate>
<TableFooter>
<td></td>
<td></td>
<td></td>
<td></td>
</TableFooter>
</DataTable>
</div>

View File

@@ -0,0 +1,60 @@
// <copyright file="Plugins.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using Microsoft.AspNetCore.Components;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Code-behind for the <see cref="Plugins"/> page.
/// </summary>
public partial class Plugins
{
/// <summary>
/// Gets or sets the plug-in identifier.
/// </summary>
[SupplyParameterFromQuery(Name = "id")]
public string? PlugInId { get; set; }
[Inject]
private PlugInController PlugInController { get; set; } = null!;
/// <inheritdoc />
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync().ConfigureAwait(true);
if (Guid.TryParse(this.PlugInId, out var id))
{
var plugin = await this.PlugInController.GetByIdAsync(id).ConfigureAwait(true);
if (plugin is { })
{
this.PlugInController.NameFilter = plugin.PlugInName ?? string.Empty;
this.PlugInController.TypeFilter = string.Empty;
this.PlugInController.PointFilter = Guid.Empty;
if (plugin.ConfigurationType is { })
{
_ = Task.Run(async () =>
{
await Task.Delay(100).ConfigureAwait(false);
await this.InvokeAsync(() => this.PlugInController.ShowPlugInConfigAsync(plugin)).ConfigureAwait(false);
});
}
}
this.PlugInId = null;
}
}
private void OnPlugInPointSelected(ChangeEventArgs args)
{
if (args.Value is string guidString && Guid.TryParse(guidString, out var result))
{
this.PlugInController.PointFilter = result;
}
}
}

View File

@@ -0,0 +1,236 @@
@page "/servers"
@using System.ComponentModel
@using Microsoft.Extensions.DependencyInjection
@using Blazored.Toast.Services
@using MUnique.OpenMU.Interfaces
@using MUnique.OpenMU.Web.AdminPanel.Properties
@implements IDisposable;
<PageTitle>OpenMU: @Resources.Servers</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.Servers"/>
<h1>@Resources.Servers</h1>
@if (_servers == null)
{
<p><em>@Resources.Loading</em></p>
}
else
{
@if (_gameServers is not null)
{
<div class="card mb-3">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label for="global-message" class="form-label mb-1">@Resources.GlobalMessage</label>
<input id="global-message" type="text" class="form-control" placeholder="@Resources.MessagePlaceholder" @bind="_message" @bind:event="oninput" />
</div>
<div class="col-md-4">
<label for="server-select" class="form-label mb-1">@Resources.Target</label>
<select id="server-select" class="form-select" @bind="_selectedServerId">
<option value="-1">@Resources.AllGameServers</option>
@foreach (var server in _servers.Where(s => s.Type == ServerType.GameServer && s.ServerState == ServerState.Started))
{
<option value="@server.Id">@server.Description (@(string.Format(Resources.OnlineCount, server.CurrentConnections)))</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" @onclick="SendMessageAsync" disabled="@string.IsNullOrWhiteSpace(_message)">
@Resources.Send
</button>
</div>
</div>
</div>
</div>
}
<div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th class="col-sm-7">@Resources.ServerName</th>
<th class="col-sm-1">@Resources.PlayerCount</th>
<th class="col-sm-2">@Resources.CurrentState</th>
<th class="col-sm-2">@Resources.Action</th>
</tr>
</thead>
<tfoot>
<TotalOnlineCounter Servers=@_servers />
</tfoot>
<tbody>
@foreach (var server in this._servers.OrderBy(s => s.Type).ThenBy(s => s.Description))
{
<ServerItem Server=@server/>
}
<tr>
<td></td>
<td>
<NavLink class="btn btn-primary me-1" href="create-game-server">
<span class="oi oi-plus"></span> @Resources.GameServer
</NavLink>
<NavLink class="btn btn-primary" href="create-connect-server">
<span class="oi oi-plus"></span> @Resources.ConnectServer
</NavLink>
</td>
<td></td>
<td></td>
<td>
@if (this.ServerInstanceManager is not null)
{
@if (this._isRestarting)
{
<button type="button" class="btn btn-warning btn-sm" disabled="disabled">
<div class="spinner-border text-secondary" role="status">
</div>
</button>
}
else
{
<button type="button" class="btn btn-warning btn-sm" @onclick="this.OnReloadAndRestartClickAsync">
<span class="oi oi-reload"></span>
@Resources.ReloadConfigurationAndRestartAllGameServers
</button>
}
}
</td>
</tr>
</tbody>
</table>
</div>
}
@code {
private IList<IManageableServer>? _servers;
private bool _isRestarting;
private string _message = string.Empty;
private int _selectedServerId = -1;
/// <summary>
/// Gets or sets the <see cref="IServerProvider"/>.
/// </summary>
[Inject]
public IServerProvider ServerProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the <see cref="IGameServerInstanceManager"/>.
/// </summary>
[Inject]
public IGameServerInstanceManager? ServerInstanceManager { get; set; }
/// <summary>
/// Gets or sets the service provider, used to optionally resolve the game server dictionary.
/// </summary>
[Inject]
public IServiceProvider ServiceProvider { get; set; } = null!;
/// <summary>
/// Gets or sets the toast service.
/// </summary>
[Inject]
public IToastService ToastService { get; set; } = null!;
/// <summary>
/// The game servers, resolved optionally: the dictionary is only registered in the
/// single-process host, so it is null on the distributed (Dapr) admin panel host. When
/// null, the global message card is not rendered (graceful degradation).
/// </summary>
private IDictionary<int, IGameServer>? _gameServers;
/// <inheritdoc />
public void Dispose()
{
this.ServerProvider.PropertyChanged -= this.OnServersChanged;
}
/// <inheritdoc />
protected override void OnInitialized()
{
base.OnInitialized();
this._servers = this.ServerProvider.Servers;
this._gameServers = this.ServiceProvider.GetService<IDictionary<int, IGameServer>>();
this.ServerProvider.PropertyChanged += this.OnServersChanged;
}
private void OnServersChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(IServerProvider.Servers))
{
this._servers = this.ServerProvider.Servers;
this.InvokeAsync(this.StateHasChanged);
}
}
private async Task OnReloadAndRestartClickAsync()
{
this._isRestarting = true;
try
{
await this.ServerInstanceManager!.RestartAllAsync(false);
}
finally
{
this._isRestarting = false;
}
}
private async Task SendMessageAsync()
{
var message = _message;
if (string.IsNullOrWhiteSpace(message) || this._gameServers is not { } gameServers)
{
return;
}
// Clear the input immediately (on the sync context) so the Send button gets disabled,
// preventing double-submission and preserving any text the user types while sending.
_message = string.Empty;
var targets = _selectedServerId == -1
? gameServers.Values.Where(s => s.ServerState == ServerState.Started).ToList()
: gameServers.TryGetValue(_selectedServerId, out var selected) && selected.ServerState == ServerState.Started
? new List<IGameServer> { selected }
: new List<IGameServer>();
if (targets.Count == 0)
{
// No running target (e.g. the selected server stopped meanwhile). Restore the text so
// the admin can pick another target and retry without retyping.
_message = message;
this.ToastService.ShowInfo(Resources.GlobalMessageNoTarget);
return;
}
var sent = 0;
foreach (var server in targets)
{
try
{
await server.SendGlobalMessageAsync(message, MessageType.GoldenCenter);
sent++;
}
catch (Exception ex)
{
// Per-server catch so one failing server does not abort the broadcast to the rest.
this.ToastService.ShowError(string.Format(Resources.GlobalMessageSendFailed, server.Description, ex.Message));
}
}
if (sent == targets.Count)
{
this.ToastService.ShowSuccess(Resources.GlobalMessageSent);
}
else if (sent == 0)
{
// Nothing got through: restore the text so the whole broadcast can be retried.
_message = message;
}
}
}

View File

@@ -0,0 +1,41 @@
@page "/setup"
@using MUnique.OpenMU.Web.AdminPanel.Properties
<PageTitle>OpenMU: @Resources.Setup</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@Resources.Setup"/>
@if (this.ShowInstall)
{
<Install InstallationFinished="() => this.ShowInstall = false" />
}
else if (!this.SetupService.CanConnectToDatabase)
{
<p>@Resources.DatabaseStatus: <span class="badge bg-danger">@Resources.CantConnectToTheDatabaseProbablyNotCreatedYet</span></p>
<button class="btn btn-primary" @onclick="this.OnInstallClick">@Resources.Create</button>
}
else if (!this.SetupService.IsInstalled)
{
<p>@Resources.DatabaseStatus: <span class="badge bg-warning text-dark">@Resources.NotCreated</span></p>
<button class="btn btn-primary" @onclick="this.OnInstallClick">@Resources.Create</button>
}
else if (this.SetupService.IsUpdateRequired)
{
<p>@Resources.DatabaseStatus: <span class="badge bg-warning text-dark">@Resources.UpdateRequired</span></p>
<button class="btn btn-primary" @onclick="this.OnUpdateClickAsync">@Resources.Update</button>
}
else
{
<p>@Resources.DatabaseStatus: <span class="badge bg-success">@Resources.UpToDate</span></p>
@if (!this._isDataInitialized)
{
<p>@Resources.InitializedGameVersion: <span class="badge bg-warning text-dark">@Resources.NoInitializedDataFound</span></p>
}
else
{
<p>@Resources.InitializedGameVersion: <span class="badge bg-success">@this._gameClientVersion</span></p>
}
<button class="btn btn-warning" @onclick="this.OnReInstallClickAsync">@Resources.ReInstall</button>
}

View File

@@ -0,0 +1,68 @@
// <copyright file="Setup.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.Web.AdminPanel.Components;
using MUnique.OpenMU.Web.AdminPanel.Properties;
using MUnique.OpenMU.Web.AdminPanel.Services;
/// <summary>
/// The set up page.
/// </summary>
public partial class Setup
{
private bool _isDataInitialized;
private ClientVersion? _gameClientVersion;
/// <summary>
/// Gets or sets a value indicating whether to show the <see cref="Install"/> component.
/// </summary>
public bool ShowInstall { get; set; }
/// <summary>
/// Gets or sets the setup service.
/// </summary>
[Inject]
public SetupService SetupService { get; set; } = null!;
/// <summary>
/// Gets or sets the javascript runtime.
/// </summary>
[Inject]
public IJSRuntime JsRuntime { get; set; } = null!;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
this._isDataInitialized = await this.SetupService.IsDataInitializedAsync().ConfigureAwait(false);
if (this._isDataInitialized)
{
this._gameClientVersion = await this.SetupService.GetCurrentGameClientVersionAsync().ConfigureAwait(false);
}
}
private Task OnUpdateClickAsync()
{
return this.SetupService.InstallUpdatesAsync(default);
}
private void OnInstallClick()
{
this.ShowInstall = true;
}
private async Task OnReInstallClickAsync()
{
if (await this.JsRuntime.InvokeAsync<bool>("confirm", Resources.ReinstallConfirmation).ConfigureAwait(false))
{
this.ShowInstall = true;
}
}
}

View File

@@ -0,0 +1,93 @@
@page "/config-updates"
@using MUnique.OpenMU.DataModel
@using MUnique.OpenMU.DataModel.Configuration
@using MUnique.OpenMU.Web.AdminPanel.Properties
<PageTitle>OpenMU: @typeof(ConfigurationUpdate).GetPluralizedTypeCaption()</PageTitle>
<Breadcrumb IsFirstFromRoot="true" Caption="@typeof(ConfigurationUpdate).GetPluralizedTypeCaption()" />
<h3>@typeof(ConfigurationUpdate).GetPluralizedTypeCaption()</h3>
@if (!this.SetupService.IsInstalled || this.SetupService.IsUpdateRequired)
{
<div class="alert alert-info" role="alert"><NavLink href="setup">@Resources.PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage</NavLink></div>
return;
}
@if (!this._availableUpdates.Any())
{
<div class="alert alert-success" role="alert">@Resources.NoConfigurationDataUpdateAvailable</div>
return;
}
<div class="alert alert-light">
<p>@Resources.NewConfigurationUpdatesAvailable</p>
<p>@Resources.MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected</p>
<p>@Resources.TheUpdatesRequireARestartOfTheServerProcessToTakeEffect</p>
</div>
@foreach (var update in this._availableUpdates)
{
<div class="alert @(update.State == UpdateState.Failed ? "alert-warning" : update.Selected ? "alert-primary" : "alert-secondary")" role="alert">
@if (update.State == UpdateState.Started)
{
<div class="spinner-border text-secondary spinner-border-sm" role="status">
<span class="visually-hidden">@Resources.Updating</span>
</div>
}
else if (update.State == UpdateState.Failed)
{
<span class="oi oi-bolt"></span>
}
else if (update.State == UpdateState.Installed)
{
<span class="oi oi-check"></span>
}
else if (update.IsMandatory)
{
<InputCheckbox class="form-check-input" @bind-Value="@update.Selected" disabled="disabled"></InputCheckbox>
}
else
{
<InputCheckbox class="form-check-input" @bind-Value="@update.Selected"></InputCheckbox>
}
<span>&nbsp;@update.Name (#@update.Version)</span>
<hr/>
<div class="alert alert-secondary">
<span>@update.Description</span>
</div>
</div>
}
@if (this._overallState == UpdateState.Started)
{
<button class="btn btn-primary" type="button" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
@Resources.ApplyingUpdates
</button>
}
else if (this._overallState == UpdateState.Failed)
{
<button class="btn btn-primary" type="button" disabled>
<span class="oi oi-bolt" role="status" aria-hidden="true"></span>
@Resources.UpdateFailed
</button>
}
else if (this._availableUpdates.Any(u => u.Selected))
{
<button class="btn btn-primary" type="button" @onclick="this.OnUpdateClickAsync">@Resources.ApplySelectedUpdates</button>
}
else
{
<button class="btn btn-primary" type="button" disabled>@Resources.ApplySelectedUpdates</button>
}
@if (this._exception is { } exception)
{
<hr/>
<div class="alert alert-danger">
<h3 class="alert-heading">
<span class="oi oi-bolt"></span><span>&nbsp;@exception.Message</span>
</h3>
<hr/>
<span>@exception.StackTrace</span>
</div>
}

View File

@@ -0,0 +1,182 @@
// <copyright file="Updates.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Pages;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.Web.AdminPanel.Services;
/// <summary>
/// The set-up page.
/// </summary>
public partial class Updates
{
private bool _isDataInitialized;
private Exception? _exception;
private UpdateState _overallState;
private List<UpdateViewModel> _availableUpdates = new();
private enum UpdateState
{
NotStarted,
Started,
Installed,
Failed,
}
/// <summary>
/// Gets or sets the setup service.
/// </summary>
[Inject]
public SetupService SetupService { get; set; } = null!;
/// <summary>
/// Gets or sets the update manager.
/// </summary>
[Inject]
public DataUpdateService UpdateService { get; set; } = null!;
/// <summary>
/// Gets or sets the JavaScript runtime.
/// </summary>
[Inject]
public IJSRuntime JsRuntime { get; set; } = null!;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await this.DetermineUpdatesAsync().ConfigureAwait(true);
}
private async Task DetermineUpdatesAsync()
{
this._isDataInitialized = await this.SetupService.IsDataInitializedAsync().ConfigureAwait(false);
if (this._isDataInitialized)
{
var updates = await this.UpdateService.DetermineAvailableUpdatesAsync().ConfigureAwait(false);
this._availableUpdates = updates.Select(up => new UpdateViewModel(up)).ToList();
}
}
private async Task OnUpdateClickAsync()
{
this._exception = null;
this._overallState = UpdateState.Started;
this.StateHasChanged();
var selectedUpdates = this._availableUpdates.Where(up => up.Selected).Select(up => up.UpdatePlugIn).ToList();
var progress = new Progress<(UpdateVersion CurrentUpdateVersion, bool IsCompleted)>();
progress.ProgressChanged += this.OnUpdateProgressChanged;
var currentUpdateVersion = UpdateVersion.Undefined;
progress.ProgressChanged += (_, args) => currentUpdateVersion = args.CurrentUpdateVersion;
try
{
await this.UpdateService.ApplyUpdatesAsync(selectedUpdates, progress).ConfigureAwait(true);
await this.DetermineUpdatesAsync().ConfigureAwait(true);
this._overallState = UpdateState.Installed;
}
catch (Exception ex)
{
this._exception = ex;
this._overallState = UpdateState.Failed;
if (this._availableUpdates.FirstOrDefault(up => up.Version == currentUpdateVersion) is { } failedUpdate)
{
failedUpdate.State = UpdateState.Failed;
}
}
finally
{
this.StateHasChanged();
}
}
private void OnUpdateProgressChanged(object? sender, (UpdateVersion CurrentUpdateVersion, bool IsCompleted) e)
{
if (this._availableUpdates.FirstOrDefault(up => up.Version == e.CurrentUpdateVersion) is not { } updateViewModel)
{
return;
}
updateViewModel.State = e.IsCompleted ? UpdateState.Installed : UpdateState.Started;
this.StateHasChanged();
}
/// <summary>
/// The view model for an update.
/// </summary>
private class UpdateViewModel
{
private readonly IConfigurationUpdatePlugIn _updatePlugIn;
private bool _selected;
private UpdateState _state;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateViewModel"/> class.
/// </summary>
/// <param name="updatePlugIn">The update plugin.</param>
public UpdateViewModel(IConfigurationUpdatePlugIn updatePlugIn)
{
this._updatePlugIn = updatePlugIn;
this.Selected = true;
}
public EventCallback<bool> SelectedChanged { get; set; }
public EventCallback<UpdateState> StateChanged { get; set; }
public bool Selected
{
get => this._selected;
set
{
if (this._selected == value)
{
return;
}
this._selected = value;
if (this.SelectedChanged.HasDelegate)
{
_ = this.SelectedChanged.InvokeAsync(value);
}
}
}
public UpdateState State
{
get => this._state;
set
{
if (this.State == value)
{
return;
}
this._state = value;
if (this.StateChanged.HasDelegate)
{
_ = this.StateChanged.InvokeAsync(value);
}
}
}
public string Name => this._updatePlugIn.Name;
public UpdateVersion Version => this._updatePlugIn.Version;
public string Description => this._updatePlugIn.Description;
public bool IsMandatory => this._updatePlugIn.IsMandatory;
public IConfigurationUpdatePlugIn UpdatePlugIn => this._updatePlugIn;
}
}

View File

@@ -0,0 +1,90 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The class of the entry point.
/// </summary>
public class Program
{
/// <summary>
/// Defines the entry point of the application.
/// </summary>
/// <param name="args">The arguments.</param>
public static void Main(string[] args)
{
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(serviceCollection =>
{
serviceCollection.AddSingleton<IList<IManageableServer>>(new List<IManageableServer>());
serviceCollection.AddSingleton<IPersistenceContextProvider>(new NullPersistenceContextProvider());
serviceCollection.AddSingleton<IConfigurationChangePublisher>(IConfigurationChangePublisher.None);
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStaticWebAssets();
webBuilder.UseStartup<Startup>();
})
.Build();
host.Run();
}
private class NullPersistenceContextProvider : IPersistenceContextProvider
{
public IRepositoryProvider RepositoryProvider => throw new NotImplementedException();
public IContext CreateNewContext()
{
throw new NotImplementedException();
}
public IContext CreateNewContext(GameConfiguration gameConfiguration)
{
throw new NotImplementedException();
}
public IConfigurationContext CreateNewConfigurationContext()
{
throw new NotImplementedException();
}
public IContext CreateNewTradeContext()
{
throw new NotImplementedException();
}
public IPlayerContext CreateNewPlayerContext(GameConfiguration gameConfiguration)
{
throw new NotImplementedException();
}
public IFriendServerContext CreateNewFriendServerContext()
{
throw new NotImplementedException();
}
public IGuildServerContext CreateNewGuildContext()
{
throw new NotImplementedException();
}
public IContext CreateNewTypedContext(Type editType, bool useCache, GameConfiguration? gameConfiguration = null)
{
throw new NotImplementedException();
}
public IContext CreateNewUpdateContext()
{
throw new NotImplementedException();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,615 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Create" xml:space="preserve">
<value>Create</value>
</data>
<data name="Reload" xml:space="preserve">
<value>Reload</value>
</data>
<data name="AdminUsers" xml:space="preserve">
<value>Admin Users</value>
</data>
<data name="Actions" xml:space="preserve">
<value>Actions</value>
</data>
<data name="ChangePassword" xml:space="preserve">
<value>Change password</value>
</data>
<data name="Delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="Duplicate" xml:space="preserve">
<value>Duplicate</value>
</data>
<data name="DuplicatedSuccessfully" xml:space="preserve">
<value>Duplicated '{0}' successfully.</value>
</data>
<data name="CouldNotFindToDuplicate" xml:space="preserve">
<value>Couldn't find '{0}' to duplicate.</value>
</data>
<data name="TypeDoesNotSupportCloning" xml:space="preserve">
<value>Type '{0}' does not support cloning.</value>
</data>
<data name="FailedToClone" xml:space="preserve">
<value>Failed to clone '{0}'.</value>
</data>
<data name="ErrorDuplicating" xml:space="preserve">
<value>Error duplicating '{0}': {1}</value>
</data>
<data name="CreateUser" xml:space="preserve">
<value>Create User</value>
</data>
<data name="Loading" xml:space="preserve">
<value>Loading ...</value>
</data>
<data name="CreateConnectServer" xml:space="preserve">
<value>Create Connect Server</value>
</data>
<data name="CreateGameServer" xml:space="preserve">
<value>Create Game Server</value>
</data>
<data name="ServerWithIdAlreadyExists" xml:space="preserve">
<value>Server with Id {0} already exists. Please use another value.</value>
</data>
<data name="ServerWithPortAlreadyExists" xml:space="preserve">
<value>A server with tcp port {0} already exists. Please use another tcp port.</value>
</data>
<data name="CreatingConfigurationInfo" xml:space="preserve">
<value>Creating Configuration ...</value>
</data>
<data name="SavingConfigurationInfo" xml:space="preserve">
<value>Saving Configuration ...</value>
</data>
<data name="ConnectionServerConfigurationSaved" xml:space="preserve">
<value>The connection server configuration has been saved. Initializing connect server ...</value>
</data>
<data name="InitializingConnectServerInfo" xml:space="preserve">
<value>Initializing Connect Server ...</value>
</data>
<data name="NoChangesSaved" xml:space="preserve">
<value>No changes have been saved.</value>
</data>
<data name="UnexpectedErrorOccurred" xml:space="preserve">
<value>An unexpected error occurred: {0}.</value>
</data>
<data name="GameServerConfigurationSavedInfo" xml:space="preserve">
<value>The game server configuration has been saved. Initializing game server ...</value>
</data>
<data name="InitializingGameServerInfo" xml:space="preserve">
<value>Initializing Game Server ...</value>
</data>
<data name="Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="Refresh" xml:space="preserve">
<value>Refresh</value>
</data>
<data name="SavedChanges" xml:space="preserve">
<value>The changes have been saved.</value>
</data>
<data name="NoChangesToSave" xml:space="preserve">
<value>There were no changes to save.</value>
</data>
<data name="FailedByUninitializedContext" xml:space="preserve">
<value>Failed, context not initialized</value>
</data>
<data name="UnsavedChangesQuestion" xml:space="preserve">
<value>There are unsaved changes. Are you sure you want to discard them?</value>
</data>
<data name="DownloadAsJson" xml:space="preserve">
<value>Download as JSON</value>
</data>
<data name="LoadingErrorCheckLog" xml:space="preserve">
<value>Could not load the data. Check the logs for details.</value>
</data>
<data name="Error" xml:space="preserve">
<value>Error</value>
</data>
<data name="MapEditor" xml:space="preserve">
<value>Map Editor</value>
</data>
<data name="Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="AddNew" xml:space="preserve">
<value>Add New</value>
</data>
<data name="CouldNotLoadMapDataCheckTheLogs" xml:space="preserve">
<value>Could not load the map data. Check the logs for details.</value>
</data>
<data name="UnexpectedErrorCheckLogs" xml:space="preserve">
<value>An unexpected error occurred: {0}. See logs for more details.</value>
</data>
<data name="UnhandledErrorOccurred" xml:space="preserve">
<value>An unhandled error has occurred.</value>
</data>
<data name="DevelopmentEnvironmentWarning" xml:space="preserve">
<value>&lt;strong&gt;The Development environment shouldn't be enabled for deployed applications.&lt;/strong&gt;
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the &lt;strong&gt;Development&lt;/strong&gt; environment by setting the &lt;strong&gt;ASPNETCORE_ENVIRONMENT&lt;/strong&gt; environment variable to &lt;strong&gt;Development&lt;/strong&gt;
and restarting the app.</value>
</data>
<data name="SwappingToDevForMoreInformation" xml:space="preserve">
<value>Swapping to &lt;strong&gt;Development&lt;/strong&gt; environment will display more detailed information about the error that occurred.</value>
</data>
<data name="AnErrorOccurredWhileProcessingYourRequest" xml:space="preserve">
<value>An error occurred while processing your request</value>
</data>
<data name="DevelopmentMode" xml:space="preserve">
<value>Development Mode</value>
</data>
<data name="GameServer" xml:space="preserve">
<value>Game Server</value>
</data>
<data name="WelcomeMessage" xml:space="preserve">
<value>Welcome to the admin panel of OpenMU.</value>
</data>
<data name="OpenMUAdminPanel" xml:space="preserve">
<value>OpenMU AdminPanel</value>
</data>
<data name="About" xml:space="preserve">
<value>About</value>
</data>
<data name="LogFiles" xml:space="preserve">
<value>Log Files</value>
</data>
<data name="Logs" xml:space="preserve">
<value>Logs</value>
</data>
<data name="Metrics" xml:space="preserve">
<value>Metrics</value>
</data>
<data name="Tracing" xml:space="preserve">
<value>Tracing</value>
</data>
<data name="FileName" xml:space="preserve">
<value>File name</value>
</data>
<data name="LastUpdate" xml:space="preserve">
<value>Last update</value>
</data>
<data name="Size" xml:space="preserve">
<value>Size</value>
</data>
<data name="ServerID" xml:space="preserve">
<value>Server-ID</value>
</data>
<data name="Disconnect" xml:space="preserve">
<value>Disconnect</value>
</data>
<data name="LiveMap" xml:space="preserve">
<value>Live Map</value>
</data>
<data name="All" xml:space="preserve">
<value>All</value>
</data>
<data name="Merchants" xml:space="preserve">
<value>Merchants</value>
</data>
<data name="Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="DiscardChanges" xml:space="preserve">
<value>Discard changes</value>
</data>
<data name="SaveChanges" xml:space="preserve">
<value>Save changes</value>
</data>
<data name="ExtensionPoint" xml:space="preserve">
<value>Extension Point</value>
</data>
<data name="PluginName" xml:space="preserve">
<value>Plugin Name</value>
</data>
<data name="PluginType" xml:space="preserve">
<value>Plugin Type</value>
</data>
<data name="Plugins" xml:space="preserve">
<value>Plugins</value>
</data>
<data name="Deactivate" xml:space="preserve">
<value>Deactivate</value>
</data>
<data name="Activate" xml:space="preserve">
<value>Activate</value>
</data>
<data name="Servers" xml:space="preserve">
<value>Servers</value>
</data>
<data name="ServerName" xml:space="preserve">
<value>Server Name</value>
</data>
<data name="PlayerCount" xml:space="preserve">
<value>Players</value>
</data>
<data name="CurrentState" xml:space="preserve">
<value>Current State</value>
</data>
<data name="ConnectServer" xml:space="preserve">
<value>Connect Server</value>
</data>
<data name="ReloadConfigurationAndRestartAllGameServers" xml:space="preserve">
<value>Reload configuration and restart all Game Servers</value>
</data>
<data name="DatabaseStatus" xml:space="preserve">
<value>Database status</value>
</data>
<data name="CantConnectToTheDatabaseProbablyNotCreatedYet" xml:space="preserve">
<value>Can't connect to the database. Probably not created yet.</value>
</data>
<data name="InitializedGameVersion" xml:space="preserve">
<value>Initialized game version</value>
</data>
<data name="NoInitializedDataFound" xml:space="preserve">
<value>No initialized data found!</value>
</data>
<data name="ReInstall" xml:space="preserve">
<value>Re-install</value>
</data>
<data name="Update" xml:space="preserve">
<value>Update</value>
</data>
<data name="UpToDate" xml:space="preserve">
<value>Up-to-date</value>
</data>
<data name="NotCreated" xml:space="preserve">
<value>Not created</value>
</data>
<data name="UpdateRequired" xml:space="preserve">
<value>Update required</value>
</data>
<data name="Setup" xml:space="preserve">
<value>Setup</value>
</data>
<data name="Home" xml:space="preserve">
<value>Home</value>
</data>
<data name="Accounts" xml:space="preserve">
<value>Accounts</value>
</data>
<data name="OnlineAccounts" xml:space="preserve">
<value>Online Accounts</value>
</data>
<data name="Updates" xml:space="preserve">
<value>Updates</value>
</data>
<data name="AvailableUpdates" xml:space="preserve">
<value>available updates</value>
</data>
<data name="Users" xml:space="preserve">
<value>Users</value>
</data>
<data name="System" xml:space="preserve">
<value>System</value>
</data>
<data name="GameClients" xml:space="preserve">
<value>Game clients</value>
</data>
<data name="Monsters" xml:space="preserve">
<value>Monsters</value>
</data>
<data name="MerchantStores" xml:space="preserve">
<value>Merchant stores</value>
</data>
<data name="CharacterClasses" xml:space="preserve">
<value>Character classes</value>
</data>
<data name="Skills" xml:space="preserve">
<value>Skills</value>
</data>
<data name="Items" xml:space="preserve">
<value>Items</value>
</data>
<data name="DropItemGroups" xml:space="preserve">
<value>Drop item groups</value>
</data>
<data name="GameMaps" xml:space="preserve">
<value>Game maps</value>
</data>
<data name="MiniGames" xml:space="preserve">
<value>Mini games</value>
</data>
<data name="WarpList" xml:space="preserve">
<value>Warp list</value>
</data>
<data name="JewelMixes" xml:space="preserve">
<value>Jewel mixes</value>
</data>
<data name="General" xml:space="preserve">
<value>General</value>
</data>
<data name="FullConfiguration" xml:space="preserve">
<value>Full configuration</value>
</data>
<data name="GameConfiguration" xml:space="preserve">
<value>Game configuration</value>
</data>
<data name="ReinstallConfirmation" xml:space="preserve">
<value>Are you sure? All the current data is getting deleted and freshly installed.</value>
</data>
<data name="PleaseFirstInstallTheDatabaseUpdatesOnTheSetupPage" xml:space="preserve">
<value>Please, first install the database updates on the setup page.</value>
</data>
<data name="NoConfigurationDataUpdateAvailable" xml:space="preserve">
<value>No configuration data update available.</value>
</data>
<data name="NewConfigurationUpdatesAvailable" xml:space="preserve">
<value>New updates for the configuration data are available. You can select the ones which should be applied to your configuration.</value>
</data>
<data name="MandatoryUpdatesAreAlwaysAppliedAndCannotBeDeselected" xml:space="preserve">
<value>Mandatory updates are always applied and cannot be deselected.</value>
</data>
<data name="TheUpdatesRequireARestartOfTheServerProcessToTakeEffect" xml:space="preserve">
<value>The updates require a restart of the server process to take effect.</value>
</data>
<data name="Updating" xml:space="preserve">
<value>Updating...</value>
</data>
<data name="ApplyingUpdates" xml:space="preserve">
<value>Applying updates ...</value>
</data>
<data name="UpdateFailed" xml:space="preserve">
<value>Update Failed!</value>
</data>
<data name="ApplySelectedUpdates" xml:space="preserve">
<value>Apply selected updates</value>
</data>
<data name="PatchVersion" xml:space="preserve">
<value>Patch-Version</value>
</data>
<data name="MajorVersion" xml:space="preserve">
<value>Major</value>
</data>
<data name="PatchAddress" xml:space="preserve">
<value>Patch-Address</value>
</data>
<data name="Seconds" xml:space="preserve">
<value>seconds</value>
</data>
<data name="MinorVersion" xml:space="preserve">
<value>Minor</value>
</data>
<data name="Patch" xml:space="preserve">
<value>Patch</value>
</data>
<data name="Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="InstallingPleaseWait" xml:space="preserve">
<value>Installing, please wait ...</value>
</data>
<data name="PleaseRestartTheConnectAndGameServerContainers" xml:space="preserve">
<value>Please restart the connect and game server containers.</value>
</data>
<data name="FinishedHaveFun" xml:space="preserve">
<value>Finished! Have fun :)</value>
</data>
<data name="FirstCloseAllConnectionsToTheServer" xml:space="preserve">
<value>First, close all connections to the server.</value>
</data>
<data name="OK" xml:space="preserve">
<value>OK</value>
</data>
<data name="SelectTheGameVersion" xml:space="preserve">
<value>Select the game version</value>
</data>
<data name="HowManyGameServersQuestion" xml:space="preserve">
<value>How many game servers do you want?</value>
</data>
<data name="TestAccountsQuestion" xml:space="preserve">
<value>Do you want test accounts?</value>
</data>
<data name="YesCreateTestAccounts" xml:space="preserve">
<value>Yes, create test accounts</value>
</data>
<data name="StartInstall" xml:space="preserve">
<value>Start install</value>
</data>
<data name="Remove" xml:space="preserve">
<value>Remove</value>
</data>
<data name="Yes" xml:space="preserve">
<value>Yes</value>
</data>
<data name="No" xml:space="preserve">
<value>No</value>
</data>
<data name="Start" xml:space="preserve">
<value>Start</value>
</data>
<data name="Stop" xml:space="preserve">
<value>Stop</value>
</data>
<data name="RemoveServer" xml:space="preserve">
<value>Remove Server</value>
</data>
<data name="ServerDeleteProceedQuestion" xml:space="preserve">
<value>The server will be deleted from the database. Are you sure to proceed?</value>
</data>
<data name="ServerControl" xml:space="preserve">
<value>Server control</value>
</data>
<data name="TotalPlayers" xml:space="preserve">
<value>Total Players</value>
</data>
<data name="SocketNumber" xml:space="preserve">
<value>Socket {0}</value>
</data>
<data name="Action" xml:space="preserve">
<value>Action</value>
</data>
<data name="GameServerCount" xml:space="preserve">
<value>Game server count</value>
</data>
<data name="Character" xml:space="preserve">
<value>Character</value>
</data>
<data name="StartedAt" xml:space="preserve">
<value>Started At</value>
</data>
<data name="ActiveOfflinePlayer" xml:space="preserve">
<value>Active Offline Player</value>
</data>
<data name="CouldNotFindToDelete" xml:space="preserve">
<value>Couldn't find '{0}' to delete.</value>
</data>
<data name="DeleteFailedReferenced" xml:space="preserve">
<value>Couldn't delete '{0}', probably because it's referenced by another object. For details, see log</value>
</data>
<data name="CreatedSuccessfully" xml:space="preserve">
<value>New object successfully created.</value>
</data>
<data name="ShowEntryForm" xml:space="preserve">
<value>Show entry form</value>
</data>
<data name="HideEntryForm" xml:space="preserve">
<value>Hide entry form</value>
</data>
<data name="AllGameServers" xml:space="preserve">
<value>All Game Servers</value>
</data>
<data name="GlobalMessage" xml:space="preserve">
<value>Global Message</value>
</data>
<data name="GlobalMessageNoTarget" xml:space="preserve">
<value>No running game server to send the message to.</value>
</data>
<data name="GlobalMessageSendFailed" xml:space="preserve">
<value>Failed to send the message to {0}: {1}</value>
</data>
<data name="GlobalMessageSent" xml:space="preserve">
<value>Message sent.</value>
</data>
<data name="MessagePlaceholder" xml:space="preserve">
<value>Enter message...</value>
</data>
<data name="OnlineCount" xml:space="preserve">
<value>{0} online</value>
</data>
<data name="Send" xml:space="preserve">
<value>Send</value>
</data>
<data name="Target" xml:space="preserve">
<value>Target</value>
</data>
</root>

View File

@@ -0,0 +1,91 @@
# Admin Panel
The admin panel is meant to offer functions for administrative tasks.
It's implemented with ASP.NET Core Blazor Server and it's accessible via <http://localhost/> or <http://localhost/admin/>
The current features are:
## Server list
* Start / Shutdown
* Player count monitoring
* Links to show live maps (see below)
Ideas for the future:
* Expand-Buttons to show the players which are playing on a server
* Button to disconnect a player
## Edit Pages
To be able edit most of the data without writing some SQL, there are a generic
edit pages which is generated automatically by reflection.
Some fields can't be edited or created yet, because not all have a corresponding
Component yet.
Also keep in mind, these pages are a very technical and a generic view of the data,
so you need to know what you're doing.
More user-friendly configuration and account/character editors are planned for
the future.
## Account list
It shows the list of accounts, ordered by the login name. Functions:
* Creating new accounts
* Banning/deactivating accounts
* Clicking on Edit sends you to the generic edit page for the account.
For example, creating Characters involves some initialization logic which
is not done yet on the web interface.
## Game Configuration
It's possible to edit every bit of the game configuration by the generic edit page.
## Log view
It's possible to view a real-time log of the server. Because a server can generate
a lot of log messages, there are some filter-features to see only messages of a
specific player, server, and/or logger.
## Live map
It's a graphical representation of a specific map to monitor some kind of actions
on it:
* player / npc movements
* player attacks
It's implemented in WebGL (by three.js) and makes use of Blazors javascript interop
to update the visible entites.
Ideas for the future:
* Zooming in to monitor players more closely
* Display of all kind of skill animations
* Display of active magic effects (buffs etc.)
* Display of health status
* Functions to detect and show suspicious players
* Functions to directly ban suspicious players
* Overview with several maps on the same page
* View of public chats
* Game-Master features, such as:
* Dropping of items
* Starting automated events
* Sending chat messages
* Sending global messages (the golden ones)
## Other feature ideas
* Based on the Live Map, we could create a graphical editor for monster spawn
areas, gates, etc.

View File

@@ -0,0 +1,20 @@
// <copyright file="ConfigurationSearchEntry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Services;
/// <summary>
/// A precomputed configuration-search entry.
/// </summary>
/// <param name="Caption">The display caption.</param>
/// <param name="Path">The full path in the configuration graph.</param>
/// <param name="Url">The target edit URL.</param>
/// <param name="NormalizedHaystack">The normalized searchable text.</param>
/// <param name="NormalizedCaption">The normalized caption.</param>
public sealed record ConfigurationSearchEntry(
string Caption,
string Path,
string Url,
string NormalizedHaystack,
string NormalizedCaption);

View File

@@ -0,0 +1,166 @@
// <copyright file="ConfigurationSearchIndexCache.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Services;
using System.Diagnostics;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Caches configuration search entries for fast header search navigation.
/// </summary>
public class ConfigurationSearchIndexCache : IDisposable
{
private readonly IMigratableDatabaseContextProvider _persistenceContextProvider;
private readonly IDataSource<GameConfiguration> _configDataSource;
private readonly ILogger<ConfigurationSearchIndexCache> _logger;
private readonly SetupService _setupService;
private readonly SemaphoreSlim _loadingLock = new(1, 1);
private bool _isLoaded;
private IReadOnlyList<ConfigurationSearchEntry> _entries = Array.Empty<ConfigurationSearchEntry>();
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationSearchIndexCache"/> class.
/// </summary>
/// <param name="persistenceContextProvider">The persistence context provider.</param>
/// <param name="configDataSource">The configuration data source.</param>
/// <param name="logger">The logger.</param>
/// <param name="setupService">The setup service.</param>
public ConfigurationSearchIndexCache(
IMigratableDatabaseContextProvider persistenceContextProvider,
IDataSource<GameConfiguration> configDataSource,
ILogger<ConfigurationSearchIndexCache> logger,
SetupService setupService)
{
this._persistenceContextProvider = persistenceContextProvider;
this._configDataSource = configDataSource;
this._logger = logger;
this._setupService = setupService;
this._setupService.DatabaseInitialized += this.OnDatabaseInitializedAsync;
_ = Task.Run(async () =>
{
try
{
await this.EnsureLoadedAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogWarning(ex, "Could not warmup configuration search index.");
}
});
}
/// <summary>
/// Gets a value indicating whether the cache was loaded at least once.
/// </summary>
public bool IsLoaded => this._isLoaded;
/// <summary>
/// Gets the cached entries.
/// </summary>
public IReadOnlyList<ConfigurationSearchEntry> Entries => this._entries;
/// <inheritdoc/>
public void Dispose()
{
this._setupService.DatabaseInitialized -= this.OnDatabaseInitializedAsync;
this._loadingLock.Dispose();
}
/// <summary>
/// Ensures the cache is populated.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task.</returns>
public async Task EnsureLoadedAsync(CancellationToken cancellationToken = default)
{
if (this._isLoaded)
{
return;
}
await this._loadingLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (this._isLoaded)
{
return;
}
this._entries = await this.LoadEntriesAsync(cancellationToken).ConfigureAwait(false);
this._isLoaded = true;
}
finally
{
this._loadingLock.Release();
}
}
private async ValueTask OnDatabaseInitializedAsync()
{
await this._loadingLock.WaitAsync().ConfigureAwait(false);
try
{
this._entries = Array.Empty<ConfigurationSearchEntry>();
this._isLoaded = false;
}
finally
{
this._loadingLock.Release();
}
try
{
await this.EnsureLoadedAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogWarning(ex, "Could not load configuration search index on database initialization.");
}
}
private async Task<IReadOnlyList<ConfigurationSearchEntry>> LoadEntriesAsync(CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
if (!await this._persistenceContextProvider.CanConnectToDatabaseAsync(cancellationToken).ConfigureAwait(false)
|| !await this._persistenceContextProvider.DatabaseExistsAsync(cancellationToken).ConfigureAwait(false))
{
return Array.Empty<ConfigurationSearchEntry>();
}
using var context = this._persistenceContextProvider.CreateNewConfigurationContext();
var gameConfigurationId = await context.GetDefaultGameConfigurationIdAsync(cancellationToken).ConfigureAwait(false);
if (gameConfigurationId is not { } id || id == Guid.Empty)
{
return Array.Empty<ConfigurationSearchEntry>();
}
var gameConfiguration = await this._configDataSource.GetOwnerAsync(id, cancellationToken).ConfigureAwait(false);
this._logger.LogInformation("Configuration search data loaded in {0} ms.", stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
var result = await ConfigurationSearchIndexer.BuildSearchIndexAsync(gameConfiguration, id).ConfigureAwait(false);
stopwatch.Stop();
this._logger.LogInformation(
"Configuration search index loaded with {0} entries in {1} ms.",
result.Count,
stopwatch.ElapsedMilliseconds);
return result;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Could not load the configuration search index.");
return Array.Empty<ConfigurationSearchEntry>();
}
}
}

View File

@@ -0,0 +1,402 @@
// <copyright file="ConfigurationSearchIndexer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Services;
using System.Collections;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Text;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Composition;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Handles the heavy lifting of building the configuration search index.
/// </summary>
internal sealed class ConfigurationSearchIndexer
{
private const int MaximumTraversalDepth = 5;
private static readonly Type[] SupportedEditableTypes = GameConfigurationHelper.Enumerables.Keys.OrderByDescending(GetInheritanceDepth).ToArray();
private static readonly ConcurrentDictionary<Type, Type?> EditableTypeByRuntimeType = new();
private static readonly ConcurrentDictionary<Type, IReadOnlyList<PropertyInfo>> SearchablePropertiesCache = new();
private static readonly ConcurrentDictionary<(Type, PropertyInfo), string> PropertyCaptionCache = new();
private static readonly ConcurrentDictionary<Type, string> TypeCaptionCache = new();
private static readonly ConcurrentDictionary<PropertyInfo, Func<object, object?>> GetterCache = new();
private readonly StringBuilder _pathBuilder = new(512);
private readonly StringBuilder _haystackBuilder = new(1024);
private readonly string _rootPath;
private readonly string _rootUrl;
private readonly int _maxDepth;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationSearchIndexer"/> class.
/// </summary>
/// <param name="rootPath">The root path.</param>
/// <param name="rootUrl">The root URL.</param>
/// <param name="sharedVisited">The shared visited dictionary.</param>
/// <param name="maxDepth">The maximum depth.</param>
private ConfigurationSearchIndexer(string rootPath, string rootUrl, ConcurrentDictionary<object, byte>? sharedVisited = null, int maxDepth = MaximumTraversalDepth)
{
this._rootPath = rootPath;
this._rootUrl = rootUrl;
this.Visited = sharedVisited ?? new ConcurrentDictionary<object, byte>(ReferenceEqualityComparer.Instance);
this._maxDepth = maxDepth;
this.Entries = new List<ConfigurationSearchEntry>(8192);
this.CollectionProperties = GetSearchableProperties(typeof(GameConfiguration))
.Where(p => typeof(IEnumerable).IsAssignableFrom(p.PropertyType) && p.PropertyType != typeof(string))
.ToList();
this.ScalarProperties = GetSearchableProperties(typeof(GameConfiguration))
.Where(p => !typeof(IEnumerable).IsAssignableFrom(p.PropertyType))
.ToList();
}
/// <summary>
/// Gets the shared visited dictionary.
/// </summary>
public ConcurrentDictionary<object, byte> Visited { get; }
/// <summary>
/// Gets the collected entries.
/// </summary>
public List<ConfigurationSearchEntry> Entries { get; }
/// <summary>
/// Gets the collection properties to traverse.
/// </summary>
public IReadOnlyList<PropertyInfo> CollectionProperties { get; }
/// <summary>
/// Gets the scalar properties (non-collection).
/// </summary>
public IReadOnlyList<PropertyInfo> ScalarProperties { get; }
/// <summary>
/// Builds the search index for the specified configuration.
/// </summary>
/// <param name="gameConfiguration">The game configuration.</param>
/// <param name="gameConfigurationId">The game configuration identifier.</param>
/// <returns>The collected search entries.</returns>
public static async Task<IReadOnlyList<ConfigurationSearchEntry>> BuildSearchIndexAsync(GameConfiguration gameConfiguration, Guid gameConfigurationId)
{
var fullTypeName = typeof(GameConfiguration).FullName;
if (fullTypeName is null)
{
return Array.Empty<ConfigurationSearchEntry>();
}
var rootPath = GetCachedTypeCaption(typeof(GameConfiguration));
var rootUrl = $"/edit-config/{fullTypeName}/{gameConfigurationId}";
var mainIndexer = new ConfigurationSearchIndexer(rootPath, rootUrl);
mainIndexer.Visited.TryAdd(gameConfiguration, 0);
// Add root entry
mainIndexer.AddEntry(rootPath, rootPath, rootUrl, typeof(GameConfiguration).Name);
// Process scalar properties (shallow, no traversal)
foreach (var property in mainIndexer.ScalarProperties)
{
var caption = GetPropertyCaption(typeof(GameConfiguration), property);
var propertyUrl = AppendSearchParameter(rootUrl, property.Name, false);
mainIndexer.AddEntry(caption, rootPath, propertyUrl, property.Name, property.PropertyType.Name, typeof(GameConfiguration).Name);
}
// Process collections in parallel
var collectionTasks = mainIndexer.CollectionProperties.Select(property => Task.Run(() =>
{
var value = GetPropertyValue(property, gameConfiguration) as IEnumerable;
if (value is null)
{
return (List<ConfigurationSearchEntry>?)null;
}
var propertyCaption = GetPropertyCaption(typeof(GameConfiguration), property);
var propertyPath = $"{rootPath} > {propertyCaption}";
var localIndexer = new ConfigurationSearchIndexer(propertyPath, rootUrl, mainIndexer.Visited);
localIndexer.TraverseCollection(value, 1);
return (List<ConfigurationSearchEntry>?)localIndexer.Entries;
})).ToList();
var taskResults = await Task.WhenAll(collectionTasks).ConfigureAwait(false);
foreach (var results in taskResults)
{
if (results is not null)
{
mainIndexer.Entries.AddRange(results);
}
}
return mainIndexer.Entries.DistinctBy(e => e.Url).ToArray();
}
private static string GetPropertyCaption(Type type, PropertyInfo propertyInfo)
{
return PropertyCaptionCache.GetOrAdd((type, propertyInfo), _ =>
propertyInfo.GetCustomAttribute<DisplayAttribute>()?.GetName()
?? type.GetPropertyCaption(propertyInfo.Name));
}
private static IReadOnlyList<PropertyInfo> GetSearchableProperties(Type type)
{
return SearchablePropertiesCache.GetOrAdd(type, t =>
t.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)
.Where(p => p.GetCustomAttribute<TransientAttribute>() is null)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable ?? true)
.Where(p => !p.Name.StartsWith("Raw", StringComparison.Ordinal))
.Where(p => !p.Name.StartsWith("Joined", StringComparison.Ordinal))
.Where(p => !p.GetIndexParameters().Any())
.ToList());
}
private static string GetItemCaption(object item, int index)
{
var typeCaption = GetCachedTypeCaption(item.GetType());
var name = item.GetName();
if (!string.IsNullOrWhiteSpace(name))
{
return $"{typeCaption}: {name}";
}
var id = item.GetId();
return id == Guid.Empty
? $"{typeCaption}: #{index + 1}"
: $"{typeCaption}: {id}";
}
private static string GetCachedTypeCaption(Type type)
{
return TypeCaptionCache.GetOrAdd(type, t => t.GetTypeCaption());
}
private static object? GetPropertyValue(PropertyInfo propertyInfo, object instance)
{
var getter = GetterCache.GetOrAdd(propertyInfo, CreateGetter);
return getter(instance);
}
private static Func<object, object?> CreateGetter(PropertyInfo propertyInfo)
{
var getter = propertyInfo.GetGetMethod();
if (getter is null)
{
return _ => null;
}
if (!propertyInfo.DeclaringType!.IsValueType && !propertyInfo.PropertyType.IsValueType)
{
try
{
return (Func<object, object?>)Delegate.CreateDelegate(typeof(Func<object, object?>), null, getter);
}
catch
{
// Fall through to GetValue
}
}
return instance => propertyInfo.GetValue(instance);
}
private static bool IsSimpleType(Type type)
{
type = Nullable.GetUnderlyingType(type) ?? type;
return type.IsPrimitive
|| type.IsEnum
|| type.IsValueType
|| type == typeof(string)
|| type == typeof(Guid)
|| type == typeof(Uri)
|| type == typeof(LocalizedString);
}
private static string? GetEditUrlForObject(object item)
{
if (item is MUnique.OpenMU.PlugIns.PlugInConfiguration plugInConfiguration)
{
return $"/plugins?id={plugInConfiguration.GetId()}";
}
var runtimeType = item.GetType();
var editableType = ResolveEditableType(runtimeType);
var fullTypeName = editableType?.FullName;
if (fullTypeName is null)
{
return null;
}
var id = item.GetId();
return id != Guid.Empty ? $"/edit-config/{fullTypeName}/{id}" : null;
}
private static Type? ResolveEditableType(Type runtimeType)
{
return EditableTypeByRuntimeType.GetOrAdd(runtimeType, type =>
{
var editableType = SupportedEditableTypes.FirstOrDefault(candidate => candidate.IsAssignableFrom(type));
if (editableType is not null)
{
return editableType;
}
return EnumerateTypeAndBaseTypes(type)
.FirstOrDefault(t =>
!t.Assembly.IsDynamic
&& t.GetProperty(nameof(IIdentifiable.Id), BindingFlags.Instance | BindingFlags.Public) is not null);
});
}
private static IEnumerable<Type> EnumerateTypeAndBaseTypes(Type type)
{
for (var current = type; current is not null && current != typeof(object); current = current.BaseType)
{
yield return current;
}
}
private static int GetInheritanceDepth(Type type)
{
var depth = 0;
for (var current = type; current is not null && current != typeof(object); current = current.BaseType)
{
depth++;
}
return depth;
}
private static string AppendSearchParameter(string url, string searchTerm, bool hasParameters)
{
if (string.IsNullOrWhiteSpace(searchTerm))
{
return url;
}
var separator = hasParameters ? "&" : "?";
return $"{url}{separator}search={Uri.EscapeDataString(searchTerm)}";
}
private void AddEntry(string caption, string path, params string[] aliases)
{
this.AddEntry(caption, path, string.Empty, aliases);
}
private void AddEntry(string caption, string path, string url, params string[] aliases)
{
this._haystackBuilder.Clear();
this._haystackBuilder.Append(caption);
foreach (var alias in aliases)
{
if (!string.IsNullOrEmpty(alias))
{
this._haystackBuilder.Append(' ');
this._haystackBuilder.Append(alias);
}
}
this._haystackBuilder.Append(' ');
this._haystackBuilder.Append(path);
this.Entries.Add(new ConfigurationSearchEntry(caption, path, url, this._haystackBuilder.ToString(), caption));
}
private void TraverseCollection(IEnumerable collection, int depth)
{
var index = 0;
foreach (var item in collection)
{
if (item is null)
{
index++;
continue;
}
var itemType = item.GetType();
var itemCaption = GetItemCaption(item, index);
var itemPath = this.BuildPath(this._rootPath, itemCaption);
var itemUrl = GetEditUrlForObject(item) ?? this._rootUrl;
this.AddEntry(itemCaption, itemPath, itemUrl, itemType.Name);
if (!IsSimpleType(itemType) && !itemType.IsValueType)
{
this.TraverseObject(item, itemUrl, itemPath, depth + 1);
}
index++;
}
}
private void TraverseObject(object current, string currentUrl, string currentPath, int depth)
{
if (depth > this._maxDepth || !this.Visited.TryAdd(current, 0))
{
return;
}
var type = current.GetType();
var hasParams = currentUrl.Contains('?', StringComparison.Ordinal);
foreach (var property in GetSearchableProperties(type))
{
var propertyCaption = GetPropertyCaption(type, property);
var propertyPath = this.BuildPath(currentPath, propertyCaption);
var propertyValue = GetPropertyValue(property, current);
var valueType = propertyValue?.GetType();
var isNavigable = propertyValue is not null
&& !IsSimpleType(valueType!)
&& (propertyValue is IEnumerable || !valueType!.IsValueType);
var propertyUrl = AppendSearchParameter(currentUrl, property.Name, hasParams);
this.AddEntry(
propertyCaption,
propertyPath,
propertyUrl,
property.Name,
property.PropertyType.Name,
type.Name);
if (propertyValue is null || propertyValue is byte[] || !isNavigable)
{
continue;
}
if (propertyValue is IEnumerable enumerable and not string)
{
this.TraverseCollection(enumerable, depth + 1);
continue;
}
if (valueType!.IsValueType)
{
continue;
}
var childUrl = GetEditUrlForObject(propertyValue) ?? propertyUrl;
var childTypeCaption = GetCachedTypeCaption(valueType);
this.AddEntry(childTypeCaption, propertyPath, childUrl, property.Name, valueType.Name);
this.TraverseObject(propertyValue, childUrl, propertyPath, depth + 1);
}
}
private string BuildPath(string parent, string child)
{
this._pathBuilder.Clear();
this._pathBuilder.Append(parent);
this._pathBuilder.Append(" > ");
this._pathBuilder.Append(child);
return this._pathBuilder.ToString();
}
}

View File

@@ -0,0 +1,138 @@
// <copyright file="SetupService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel.Services;
using System.Threading;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Persistence.Initialization;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// Service that allows set the server database up.
/// </summary>
public class SetupService
{
private readonly IMigratableDatabaseContextProvider _contextProvider;
private readonly PlugInManager _plugInManager;
private ICollection<IDataInitializationPlugIn>? _availableInitializationPlugIns;
/// <summary>
/// Initializes a new instance of the <see cref="SetupService"/> class.
/// </summary>
/// <param name="contextProvider">The context provider.</param>
/// <param name="plugInManager">The plugin manager.</param>
public SetupService(IMigratableDatabaseContextProvider contextProvider, PlugInManager plugInManager)
{
this._contextProvider = contextProvider;
this._plugInManager = plugInManager;
}
/// <summary>
/// Occurs when the database got initialized.
/// </summary>
public event AsyncEventHandler? DatabaseInitialized;
/// <summary>
/// Gets the versions.
/// </summary>
public ICollection<IDataInitializationPlugIn> Versions => this._availableInitializationPlugIns
??= (this._plugInManager.GetStrategyProvider<string, IDataInitializationPlugIn>() ?? throw new InvalidOperationException("No data initialization plugins were found."))
.AvailableStrategies
.OrderByDescending(s => s.Caption)
.ToList();
/// <summary>
/// Gets a value indicating whether this application can connect to a database.
/// </summary>
public bool CanConnectToDatabase
{
get
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
return this._contextProvider.CanConnectToDatabaseAsync(cts.Token).WaitAndUnwrapException();
}
}
/// <summary>
/// Gets a value indicating whether the data is installed.
/// </summary>
public bool IsInstalled
{
get
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
return this._contextProvider.DatabaseExistsAsync(cts.Token).WaitAndUnwrapException();
}
}
/// <summary>
/// Gets a value indicating whether the database requires an update.
/// </summary>
public bool IsUpdateRequired
{
get
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
return !this._contextProvider.IsDatabaseUpToDateAsync(cts.Token).WaitAndUnwrapException();
}
}
/// <summary>
/// Gets a value indicating whether the data is initialized.
/// </summary>
public async ValueTask<bool> IsDataInitializedAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
using var context = this._contextProvider.CreateNewConfigurationContext();
var id = await context.GetDefaultGameConfigurationIdAsync(cts.Token).ConfigureAwait(false);
return id is not null;
}
catch
{
return false;
}
}
/// <summary>
/// Gets the current game client definition.
/// </summary>
public async ValueTask<ClientVersion?> GetCurrentGameClientVersionAsync()
{
using var context = this._contextProvider.CreateNewConfigurationContext();
var definition = (await context.GetAsync<GameClientDefinition>().ConfigureAwait(false)).FirstOrDefault();
return definition is { } ? new ClientVersion(definition.Season, definition.Episode, definition.Language) : null;
}
/// <summary>
/// Installs the updates asynchronous.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task InstallUpdatesAsync(CancellationToken cancellationToken)
{
await this._contextProvider.ApplyAllPendingUpdatesAsync().ConfigureAwait(false);
await this._contextProvider.WaitForUpdatedDatabaseAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates the database.
/// </summary>
/// <param name="dataInitialization">The data initialization action.</param>
public async Task CreateDatabaseAsync(Func<Task> dataInitialization)
{
using var update = await this._contextProvider.ReCreateDatabaseAsync().ConfigureAwait(false);
await dataInitialization().ConfigureAwait(false);
if (this.DatabaseInitialized is { } eventHandler)
{
await eventHandler.Invoke().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,117 @@
// <copyright file="Startup.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Web.AdminPanel.Components;
using MUnique.OpenMU.Web.AdminPanel.Services;
using MUnique.OpenMU.Web.Shared;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Models;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// The startup class for the blazor app.
/// </summary>
/// <remarks>
/// This class is only used when running as all-in-one deployment.
/// </remarks>
public class Startup
{
/// <summary>
/// Initializes a new instance of the <see cref="Startup"/> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
public Startup(IConfiguration configuration)
{
this.Configuration = configuration;
}
/// <summary>
/// Gets the configuration.
/// </summary>
/// <value>
/// The configuration.
/// </value>
public IConfiguration Configuration { get; }
/// <summary>
/// This method gets called by the runtime. Use this method to add services to the container.
/// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940.
/// </summary>
/// <param name="services">The service collection.</param>
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorComponents()
.AddInteractiveServerComponents();
services.AddSignalR().AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new TimeSpanConverter()));
services.AddControllers()
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
services.AddSingleton<ILookupController, PersistentObjectsLookupController>();
services.AddSingleton<ConfigurationSearchIndexCache>();
services.AddScoped<AccountService>();
services.AddScoped<IDataService<Account>>(serviceProvider => serviceProvider.GetService<AccountService>()!);
services.AddScoped<PlugInController>();
services.AddScoped<IDataService<PlugInConfigurationViewItem>>(serviceProvider => serviceProvider.GetService<PlugInController>()!);
services.AddScoped<CreationPanelService>();
services.AddScoped<IChangeNotificationService, ChangeNotificationService>();
}
/// <summary>
/// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
/// </summary>
/// <param name="app">The app builder.</param>
/// <param name="env">The web host environment.</param>
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "logs")),
RequestPath = "/logs",
});
app.UseAntiforgery();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
endpoints.MapControllers();
});
}
}

View File

@@ -0,0 +1,136 @@
// <copyright file="WebApplicationExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.AdminPanel;
using System.IO;
using Blazored.Toast;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.Persistence.Initialization.VersionSeasonSix;
using MUnique.OpenMU.Web.AdminPanel.Components;
using MUnique.OpenMU.Web.AdminPanel.Services;
using MUnique.OpenMU.Web.Shared.Components.Modal;
using MUnique.OpenMU.Web.Shared.Models;
using MUnique.OpenMU.Web.Shared.Services;
/// <summary>
/// Extensions for the <see cref="WebApplicationBuilder"/>.
/// </summary>
public static class WebApplicationExtensions
{
/// <summary>
/// Adds the map application to the web app.
/// When using the DaprService, call the BuildAndConfigure-Method with the parameter to add Blazor.
/// </summary>
/// <param name="builder">The web application builder which should be configured.</param>
/// <param name="includeMapApp">If set to <c>true</c>, the map app is included.</param>
/// <returns>
/// The web application builder.
/// </returns>
public static WebApplicationBuilder AddAdminPanel(this WebApplicationBuilder builder, bool includeMapApp = false)
{
// Ensure that DataInitialization plugins will get collected - for the setup functionality.
_ = DataInitialization.Id;
var services = builder.Services;
var supportedCultures = CultureHelper
.GetAvailableCultures<Properties.Resources>()
.Select(culture => culture.TwoLetterISOLanguageName)
.ToArray();
services.AddLocalization()
.Configure<RequestLocalizationOptions>(o =>
{
o.AddSupportedCultures(supportedCultures);
o.AddSupportedUICultures(supportedCultures);
});
services.AddRazorComponents()
.AddInteractiveServerComponents();
if (includeMapApp)
{
AdminPanelEnvironment.IsHostingEmbedded = true;
}
services.AddControllers()
.ConfigureApplicationPartManager(setup =>
setup.FeatureProviders.Add(new GenericControllerFeatureProvider()));
services.AddBlazoredToast();
services.AddScoped<ModalService>();
services.AddScoped<IModalService>(sp => sp.GetRequiredService<ModalService>());
services.AddScoped<ILookupController, PersistentObjectsLookupController>();
services.AddScoped<CreationPanelService>();
services.AddSingleton<IDataSource<GameConfiguration>, GameConfigurationDataSource>();
services.AddSingleton<IDataSource<Account>, AccountDataSource>();
services.AddSingleton<ConfigurationSearchIndexCache>();
services.AddSingleton<SetupService>();
services.AddScoped<DataUpdateService>();
services.AddScoped<AccountService>();
services.AddScoped<IDataService<Account>>(serviceProvider => serviceProvider.GetService<AccountService>()!);
services.AddScoped<PlugInController>();
services.AddScoped<IDataService<PlugInConfigurationViewItem>>(serviceProvider => serviceProvider.GetService<PlugInController>()!);
services.AddScoped<IUserService, NginxHtpasswdFileUserService>();
services.AddScoped<IChangeNotificationService, ChangeNotificationService>();
services.AddScoped<NavigationHistory>();
services.AddScoped<LoggedInAccountService>();
services.AddScoped<LoadingOverlayService>();
services.AddScoped<IDataService<LoggedInAccount>>(serviceProvider => serviceProvider.GetService<LoggedInAccountService>()!);
services.AddScoped<OfflineAccountService>();
services.AddScoped<IDataService<OfflineAccount>>(serviceProvider => serviceProvider.GetService<OfflineAccountService>()!);
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
return builder;
}
/// <summary>
/// Configures the admin panel.
/// </summary>
/// <param name="app">The application.</param>
/// <returns>The configured web application.</returns>
public static WebApplication ConfigureAdminPanel(this WebApplication app)
{
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "logs")),
RequestPath = "/logs",
});
app.UseAntiforgery();
app.MapStaticAssets();
app.UseRequestLocalization();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapControllers();
AdminPanelEnvironment.IsHostingEmbedded = true;
return app;
}
}

View File

@@ -0,0 +1,26 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Http
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using Blazored.Toast
@using Blazored.Toast.Services
@using BlazorInputFile
@using MUnique.OpenMU.Web.AdminPanel
@using MUnique.OpenMU.Web.AdminPanel.Components
@using MUnique.OpenMU.Web.Shared
@using MUnique.OpenMU.Web.Shared.Components
@using MUnique.OpenMU.Web.Shared.Components.Modal
@using MUnique.OpenMU.Web.Shared.Components.Form
@using MUnique.OpenMU.Web.Shared.Components.Form.Modal
@using MUnique.OpenMU.Web.Shared.Services

View File

@@ -0,0 +1,17 @@
@inherits Microsoft.AspNetCore.Components.Forms.InputBase<MUnique.OpenMU.DataModel.Entities.ItemStorage>
<div class="equipped-container">
@for (byte slot = InventoryConstants.LeftHandSlot; slot < InventoryConstants.EquippableSlotsCount; slot++)
{
<div class=@ClassNames[slot]>
@if (this.GetViewItemOfSlot(slot) is { } viewItem)
{
<MuItem
Model="viewItem"
OnClick="async () => await this.SetSelectedItemAsync(viewItem)"
OnItemMoved="this.OnItemMovedAsync"
IsSelected="this.SelectedItem == viewItem.Item"/>
}
</div>
}
</div>

View File

@@ -0,0 +1,81 @@
// <copyright file="EquippedItems.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
using Microsoft.AspNetCore.Components;
/// <summary>
/// Component for a item box.
/// </summary>
public partial class EquippedItems
{
private static readonly string[] ClassNames =
{
"slot-left",
"slot-right",
"slot-helm",
"slot-armor",
"slot-pants",
"slot-gloves",
"slot-boots",
"slot-wings",
"slot-pet",
"slot-pendant",
"slot-ring1",
"slot-ring2",
};
/// <summary>
/// Gets or sets the selected item.
/// </summary>
[Parameter]
public Item? SelectedItem { get; set; }
/// <summary>
/// Gets or sets the callback when the selected item changed.
/// </summary>
[Parameter]
public EventCallback<Item?> SelectedItemChanged { get; set; }
/// <summary>
/// Sets the selected item.
/// </summary>
/// <param name="viewModel">The view model.</param>
public async Task SetSelectedItemAsync(ItemViewModel viewModel)
{
this.SelectedItem = viewModel.Item;
if (this.SelectedItemChanged.HasDelegate)
{
await this.SelectedItemChanged.InvokeAsync(viewModel.Item).ConfigureAwait(true);
}
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
}
/// <inheritdoc />
protected override bool TryParseValueFromString(string? value, out ItemStorage result, out string validationErrorMessage)
{
result = null!;
validationErrorMessage = string.Empty;
return false;
}
private ItemViewModel? GetViewItemOfSlot(byte slot)
{
return this.Value?.Items.FirstOrDefault(item => item.ItemSlot == slot)?.AsViewModel();
}
private async Task OnItemMovedAsync()
{
if (this.SelectedItemChanged.HasDelegate)
{
await this.SelectedItemChanged.InvokeAsync(this.SelectedItem).ConfigureAwait(true);
}
}
}

View File

@@ -0,0 +1,129 @@

.equipped-container {
--boxSize: 42px;
--storageColumns: 8;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/equipment_back.png');
width: 325px;
height: 323px;
display: flex;
position: relative;
}
.equipped-container > div {
position: absolute;
}
.slot-left {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 4);
left: 0px;
top: 96px;
}
.slot-left ::deep > .w_1 {
left: calc(var(--boxSize) * 0.5);
}
.slot-right {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 4);
left: 241px;
top: 96px;
}
.slot-right ::deep > .w_1 {
left: calc(var(--boxSize) * 0.5);
}
.slot-helm {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 2);
left: 121px;
top: 2px;
}
.slot-armor {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 3);
left: 121px;
top: 96px;
}
.slot-pants {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 2);
left: 121px;
top: 231px;
}
.slot-gloves {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 2);
left: 0px;
top: 231px;
}
.slot-boots {
width: calc(var(--boxSize) * 2);
height: calc(var(--boxSize) * 2);
left: 241px;
top: 231px;
}
.slot-wings {
width: calc(var(--boxSize) * 3);
height: calc(var(--boxSize) * 2);
left: 208px;
top: 2px;
}
.slot-wings ::deep .mu-item img {
top: 0;
left: 0;
max-width: 120%;
max-height: 150%;
}
.slot-wings ::deep > .h_3 {
top: calc(var(--boxSize) * -0.5);
}
.slot-wings ::deep > .w_4 {
width: calc(var(--boxSize) * 3);
}
.slot-wings ::deep > .w_5 {
width: calc(var(--boxSize) * 3);
}
.slot-pet {
width: calc(var(--boxSize) * 1);
height: calc(var(--boxSize) * 1);
left: 0px;
top: 2px;
}
.slot-pet ::deep > .w_1 {
left: calc(var(--boxSize) * 0.5);
}
.slot-pet ::deep > .h_1 {
top: calc(var(--boxSize) * 0.5);
}
.slot-pendant {
width: calc(var(--boxSize) * 1);
height: calc(var(--boxSize) * 1);
left: 82px;
top: 96px;
}
.slot-ring1 {
width: calc(var(--boxSize) * 1);
height: calc(var(--boxSize) * 1);
left: 82px;
top: 231px;
}
.slot-ring2 {
width: calc(var(--boxSize) * 1);
height: calc(var(--boxSize) * 1);
left: 203px;
top: 231px;
}

View File

@@ -0,0 +1,14 @@
// <copyright file="GlobalUsings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#pragma warning disable SA1200
#pragma warning disable IDE0005
global using MUnique.OpenMU.DataModel;
global using MUnique.OpenMU.DataModel.Configuration;
global using MUnique.OpenMU.DataModel.Configuration.Items;
global using MUnique.OpenMU.DataModel.Entities;
#pragma warning restore SA1200
#pragma warning restore IDE0005

View File

@@ -0,0 +1,154 @@
// <copyright file="InventoryPlacementService.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Service for calculating inventory item placement, slot availability, and positioning.
/// </summary>
public sealed class InventoryPlacementService
{
private readonly StorageType _storageType;
private readonly byte _numberOfExtensions;
/// <summary>
/// Initializes a new instance of the <see cref="InventoryPlacementService"/> class.
/// </summary>
/// <param name="storageType">The type of storage (inventory, vault, etc.).</param>
/// <param name="numberOfExtensions">The number of inventory extensions.</param>
public InventoryPlacementService(StorageType storageType, byte numberOfExtensions)
{
this._storageType = storageType;
this._numberOfExtensions = numberOfExtensions;
}
/// <summary>
/// Finds the first available slot where the item can be placed.
/// </summary>
/// <param name="item">The item to place.</param>
/// <param name="existingItems">The existing items in the storage.</param>
/// <returns>The first available slot, or null if no slot is available.</returns>
public byte? FindFreeSlot(Item item, IEnumerable<Item> existingItems)
{
if (item.Definition is null)
{
return this.FindFirstEmptySlot(existingItems);
}
var occupiedSlots = existingItems.SelectMany(this.GetItemSlots).ToHashSet();
var maxSlot = this.GetMaxSlot();
var boxOffset = this.GetBoxOffset();
var rowSize = this.GetRowSize();
var itemWidth = item.Definition.Width;
var itemHeight = item.Definition.Height;
for (byte slot = boxOffset; slot < maxSlot; slot++)
{
var column = (slot - boxOffset) % rowSize;
if (column + itemWidth > rowSize)
{
continue;
}
bool canPlace = true;
for (byte row = 0; row < itemHeight && canPlace; row++)
{
for (byte col = 0; col < itemWidth && canPlace; col++)
{
var currentSlot = (byte)(slot + (row * rowSize) + col);
if (currentSlot >= maxSlot || occupiedSlots.Contains(currentSlot))
{
canPlace = false;
}
}
}
if (canPlace)
{
return slot;
}
}
return null;
}
private byte GetMaxSlot()
{
return this._storageType switch
{
StorageType.Vault => InventoryConstants.WarehouseSize,
StorageType.VaultExtension => InventoryConstants.WarehouseSize,
StorageType.Inventory => InventoryConstants.GetInventorySize(this._numberOfExtensions),
StorageType.InventoryExtension => (byte)(InventoryConstants.RowsOfOneExtension * InventoryConstants.RowSize),
StorageType.PersonalStore => InventoryConstants.StoreSize,
StorageType.Merchant => InventoryConstants.WarehouseSize,
_ => byte.MaxValue,
};
}
private byte GetBoxOffset()
{
return this._storageType == StorageType.Inventory ? InventoryConstants.EquippableSlotsCount : (byte)0;
}
private byte? FindFirstEmptySlot(IEnumerable<Item> existingItems)
{
var occupiedSlots = existingItems.SelectMany(this.GetItemSlots).ToHashSet();
var maxSlot = this.GetMaxSlot();
var boxOffset = this.GetBoxOffset();
for (byte slot = boxOffset; slot < maxSlot; slot++)
{
if (!occupiedSlots.Contains(slot))
{
return slot;
}
}
return null;
}
private byte GetRowSize()
{
return (byte)InventoryConstants.RowSize;
}
private HashSet<byte> GetAllOccupiedSlots(IEnumerable<Item> existingItems)
{
return existingItems
.SelectMany(this.GetItemSlots)
.ToHashSet();
}
private IEnumerable<byte> GetItemSlots(Item item)
{
if (item.Definition is null)
{
yield break;
}
var startSlot = item.ItemSlot;
var width = item.Definition.Width;
var height = item.Definition.Height;
var boxOffset = this.GetBoxOffset();
var rowSize = this.GetRowSize();
if (startSlot < boxOffset)
{
yield return startSlot;
yield break;
}
for (byte row = 0; row < height; row++)
{
for (byte col = 0; col < width; col++)
{
yield return (byte)(startSlot + (row * rowSize) + col);
}
}
}
}

View File

@@ -0,0 +1,57 @@
// <copyright file="ItemExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
/// <summary>
/// Extensions for <see cref="Item"/>.
/// </summary>
public static class ItemExtensions
{
/// <summary>
/// Returns the <see cref="Item"/> as <see cref="ItemViewModel"/>.
/// </summary>
/// <param name="item">The <see cref="Item"/>.</param>
/// <returns>The <see cref="ItemViewModel"/>.</returns>
public static ItemViewModel AsViewModel(this Item item)
{
return new ItemViewModel(null, item);
}
/// <summary>
/// Moves the item one place to the left.
/// </summary>
/// <param name="item">The item.</param>
public static void MoveLeft(this Item item)
{
item.ItemSlot--;
}
/// <summary>
/// Moves the item one place to the right.
/// </summary>
/// <param name="item">The item.</param>
public static void MoveRight(this Item item)
{
item.ItemSlot++;
}
/// <summary>
/// Moves the item one place up.
/// </summary>
/// <param name="item">The item.</param>
public static void MoveUp(this Item item)
{
item.ItemSlot -= (byte)InventoryConstants.RowSize;
}
/// <summary>
/// Moves the item one place down.
/// </summary>
/// <param name="item">The item.</param>
public static void MoveDown(this Item item)
{
item.ItemSlot += (byte)InventoryConstants.RowSize;
}
}

View File

@@ -0,0 +1,105 @@
// <copyright file="ItemViewModel.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
/// <summary>
/// View-Model for an <see cref="Item"/>.
/// </summary>
public class ItemViewModel
{
/// <summary>
/// The level mapping. Not each item level has its own effects. We save some
/// traffic and storage space of item pictures with this trick.
/// </summary>
private static readonly int[] LevelMapping = { 0, 0, 0, 3, 3, 5, 5, 7, 7, 9, 9, 11, 11, 13, 13, 15, 15 };
/// <summary>
/// Initializes a new instance of the <see cref="ItemViewModel"/> class.
/// </summary>
/// <param name="parent">The parent.</param>
/// <param name="item">The item.</param>
public ItemViewModel(StorageViewModel? parent, Item item)
{
this.Parent = parent;
this.Item = item;
}
/// <summary>
/// Gets the parent storage.
/// </summary>
public StorageViewModel? Parent { get; }
/// <summary>
/// Gets the item.
/// </summary>
public Item Item { get; }
/// <summary>
/// Gets the effect level.
/// </summary>
public int EffectLevel
{
get
{
if (this.Item.IsTrainablePet() || this.Item.IsWing())
{
return 0;
}
if (this.Item.IsWearable())
{
return this.Item.Level < LevelMapping.Length ? LevelMapping[this.Item.Level] : 0;
}
return this.Item.Level;
}
}
/// <summary>
/// Gets a value indicating whether this instance is excellent.
/// </summary>
public bool IsExcellent => this.Item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
/// <summary>
/// Gets a value indicating whether this instance is ancient.
/// </summary>
public bool IsAncient => this.Item.ItemSetGroups.Any(s => s.AncientSetDiscriminator > 0);
/// <summary>
/// Gets the option suffix.
/// </summary>
public string OptionSuffix
{
get
{
if (this.IsAncient)
{
return "_a";
}
if (this.Item.Definition?.Group < 12 && this.IsExcellent)
{
return "_e";
}
return string.Empty;
}
}
/// <summary>
/// Gets the column.
/// </summary>
public int Column => this.EffectiveIndex < 0 ? -1 : this.EffectiveIndex % InventoryConstants.RowSize;
/// <summary>
/// Gets the row.
/// </summary>
public int Row => this.EffectiveIndex < 0 ? -1 : this.EffectiveIndex / InventoryConstants.RowSize;
/// <summary>
/// Gets the effective index of the item in the box.
/// </summary>
private int EffectiveIndex => this.Item.ItemSlot - this.Parent?.StartIndex ?? -InventoryConstants.LastEquippableItemSlotIndex;
}

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Content Remove="compilerconfig.json" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\.editorconfig" Link=".editorconfig" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
<PackageReference Include="Moq" />
<PackageReference Include="Nito.AsyncEx" />
</ItemGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\DataModel\MUnique.OpenMU.DataModel.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,78 @@

<div class=@($"mu-item c_{this.Model.Column} r_{this.Model.Row} w_{this.Width} h_{this.Height}")>
@if (this.Model.Item.Definition is null)
{
<div class="undefined-item"></div>
}
else
{
<img src=@($"_content/MUnique.OpenMU.Web.ItemEditor/img/items/item_{this.Model.Item.Definition.Group}_{this.Model.Item.Definition.Number}_{this.Model.EffectLevel}{this.Model.OptionSuffix}.png") alt=@this.Model.Item />
@if (this.Model.Item.IsStackable() && this.Model.Item.Durability > 1)
{
<span class="item-amount-label">@this.Model.Item.Durability</span>
}
@if (this.Model.IsAncient)
{
<span class="indicator anc"></span>
}
@if (this.Model.IsExcellent)
{
<span class="indicator exc"></span>
}
@for (int i = 0; i < this.Model.Item.SocketCount; i++)
{
var socketEquipped = this.Model.Item.ItemOptions.Any(io => io.Index == i && io.ItemOption?.OptionType == ItemOptionTypes.SocketOption);
<span class="indicator socket s@(i+1) @(socketEquipped ? "equipped" : "unequipped")"></span>
}
}
</div>
<div title=@this.Model.Item
class=@($"mu-item {(this.IsSelected ? "highlighted" : "")} mu-item-selector c_{this.Model.Column} r_{this.Model.Row} w_{this.Width} h_{this.Height}")
@onclick="this.OnClick"
tabindex="@(this.IsSelected ? 0 : -1)"
autofocus="@(this.IsSelected ? "true" : null)"
@onkeydown="OnKeyPressAsync"></div>
@if (this.IsSelected && this.TotalRows > 0)
{
<div
class=@($"mu-item c_{this.Model.Column} r_{this.Model.Row} w_{this.Width} h_{this.Height}")
tabindex="0"
@onkeydown="OnKeyPressAsync">
@if (CanMoveLeft)
{
<div class="mu-item-move-arrow left rounded-left" @onclick="MoveLeftAsync" alt="Press A to move left">
</div>
}
@if (CanMoveRight)
{
<div class="mu-item-move-arrow right rounded-right" @onclick="MoveRightAsync" alt="Press D to move right">
</div>
}
@if (CanMoveUp)
{
<div class="mu-item-move-arrow up rounded-top" @onclick="MoveUpAsync" alt="Press W to move up">
</div>
}
@if (CanMoveDown)
{
<div class="mu-item-move-arrow down rounded-bottom" @onclick="MoveDownAsync" alt="Press S to move down">
</div>
}
@if (!CanMoveUp && CanJumpUp)
{
<div class="mu-item-move-arrow up rounded-top jump" @onclick="JumpUpAsync" alt="Press W to move up">
</div>
}
@if (!CanMoveDown && CanJumpDown)
{
<div class="mu-item-move-arrow down rounded-bottom jump" @onclick="JumpDownAsync" alt="Press S to move down">
</div>
}
</div>
}

View File

@@ -0,0 +1,155 @@
// <copyright file="MuItem.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
/// <summary>
/// Component for an <see cref="Item"/>.
/// </summary>
public partial class MuItem
{
/// <summary>
/// Gets or sets the item data.
/// </summary>
[Parameter]
[Required]
public ItemViewModel Model { get; set; } = null!;
/// <summary>
/// Gets or sets the on click callback.
/// </summary>
[Parameter]
public EventCallback<MouseEventArgs> OnClick { get; set; }
/// <summary>
/// Gets or sets the callback which is called when the selected item moved.
/// </summary>
[Parameter]
public EventCallback OnItemMoved { get; set; }
/// <summary>
/// Gets or sets the callback which is called when the item should be deleted.
/// </summary>
[Parameter]
public EventCallback OnDelete { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is selected.
/// </summary>
[Parameter]
public bool IsSelected { get; set; }
private int TotalRows => this.Model.Parent?.Rows ?? 0;
private int Height => this.Model.Item.Definition?.Height ?? 1;
private int Width => this.Model.Item.Definition?.Width ?? 1;
private bool CanMoveDown => this.TotalRows > this.Model.Row + this.Height;
private bool CanMoveUp => this.Model.Row > 0;
private bool CanMoveLeft => this.Model.Column > 0;
private bool CanMoveRight => this.Model.Column + this.Width < 8;
private bool CanJumpDown => this.Model.Parent?.StorageType is StorageType.Inventory or StorageType.InventoryExtension;
private bool CanJumpUp => this.Model.Parent?.StorageType is StorageType.InventoryExtension or StorageType.PersonalStore;
private async Task OnKeyPressAsync(KeyboardEventArgs obj)
{
if (!this.IsSelected)
{
return;
}
switch (obj.Key)
{
case "w" when this.CanMoveUp:
case "ArrowUp" when this.CanMoveUp:
await this.MoveUpAsync().ConfigureAwait(true);
break;
case "s" when this.CanMoveDown:
case "ArrowDown" when this.CanMoveDown:
await this.MoveDownAsync().ConfigureAwait(true);
break;
case "w" when this.CanJumpUp:
case "ArrowUp" when this.CanJumpUp:
await this.JumpUpAsync().ConfigureAwait(true);
break;
case "s" when this.CanJumpDown:
case "ArrowDown" when this.CanJumpDown:
await this.JumpDownAsync().ConfigureAwait(true);
break;
case "d" when this.CanMoveRight:
case "ArrowRight" when this.CanMoveRight:
await this.MoveRightAsync().ConfigureAwait(true);
break;
case "a" when this.CanMoveLeft:
case "ArrowLeft" when this.CanMoveLeft:
await this.MoveLeftAsync().ConfigureAwait(true);
break;
case "Delete":
await this.OnDelete.InvokeAsync().ConfigureAwait(true);
break;
default:
// do nothing
break;
}
}
private async Task MoveLeftAsync()
{
this.Model.Item.MoveLeft();
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task MoveRightAsync()
{
this.Model.Item.MoveRight();
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task MoveUpAsync()
{
this.Model.Item.MoveUp();
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task MoveDownAsync()
{
this.Model.Item.MoveDown();
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task JumpUpAsync()
{
var jumpRows = (this.Model.Item.Definition?.Height ?? 1) + (this.Model.Parent?.EmptyRowsToPreviousStorage ?? 0);
for (var i = 0; i < jumpRows; i++)
{
this.Model.Item.MoveUp();
}
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task JumpDownAsync()
{
var jumpRows = (this.Model.Item.Definition?.Height ?? 1) + (this.Model.Parent?.EmptyRowsToNextStorage ?? 0);
// depending on how many inventory extensions we have, we must move some more rows.
for (var i = 0; i < jumpRows; i++)
{
this.Model.Item.MoveDown();
}
await this.RaiseOnItemMovedAsync().ConfigureAwait(true);
}
private async Task RaiseOnItemMovedAsync()
{
if (this.OnItemMoved.HasDelegate)
{
await this.OnItemMoved.InvokeAsync().ConfigureAwait(true);
}
}
}

View File

@@ -0,0 +1,289 @@

.mu-item img {
position: relative;
left: -38px;
top: -42px;
user-select: none;
}
.mu-item {
--boxSize: 42px;
display: block;
position: absolute;
}
.mu-item-selector {
z-index: 1;
opacity: 0;
}
.mu-item-selector:hover {
background-color: cornflowerblue;
opacity: 0.333;
}
.mu-item-selector.highlighted {
background-color: greenyellow;
opacity: 0.333;
}
.undefined-item {
height: 100%;
width: 100%;
background-color: greenyellow;
}
.mu-item-move-arrow {
position: absolute;
z-index: 2;
background-color: black;
opacity: 0.7;
}
.mu-item-move-arrow:hover {
opacity: 0.95;
}
.item-amount-label {
opacity: 0.9;
color: wheat;
background-color: black;
position: absolute;
bottom: 0;
right: 0;
line-height: 1.1em;
}
.indicator {
height: 10px;
width: 10px;
border-radius: 5px;
position: absolute;
top: 2px;
opacity: 0.9;
}
.indicator.exc {
background-color: lawngreen;
left: 2px;
}
.indicator.anc {
background-color: #00A6E2;
right: 2px;
}
.indicator.socket {
border-color: gold;
border-style: solid;
border-width: 2px;
background-color: gray;
right: 2px;
}
.indicator.socket.equipped {
background-color: gold;
}
.indicator.socket.s1 {
top: 20px;
}
.indicator.socket.s2 {
top: 32px;
}
.indicator.socket.s3 {
top: 44px;
}
.indicator.socket.s4 {
top: 56px;
}
.indicator.socket.s5 {
top: 68px;
}
.left {
left: -24px;
height: 100%;
width: 24px;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/light-chevron-left-48.png');
background-size: 100% 100%;
}
.right {
right: -24px;
height: 100%;
width: 24px;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/light-chevron-right-48.png');
background-size: 100% 100%;
}
.up {
top: -24px;
width: 100%;
height: 24px;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/light-chevron-up-48.png');
background-size: 100% 100%;
}
.down {
bottom: -24px;
width: 100%;
height: 24px;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/light-chevron-down-48.png');
background-size: 100% 100%;
}
.jump {
background-size: 100% 50%;
}
div.h_1 {
height: var(--boxSize);
}
div.h_2 {
height: calc(var(--boxSize) * 2);
}
div.h_3 {
height: calc(var(--boxSize) * 3);
}
div.h_4 {
height: calc(var(--boxSize) * 4);
}
div.w_1 {
width: var(--boxSize);
}
div.w_2 {
width: calc(var(--boxSize) * 2);
}
div.w_3 {
width: calc(var(--boxSize) * 3);
}
div.w_4 {
width: calc(var(--boxSize) * 4);
}
div.w_5 {
width: calc(var(--boxSize) * 5);
}
div.w_6 {
width: calc(var(--boxSize) * 6);
}
div.w_7 {
width: calc(var(--boxSize) * 7);
}
div.w_8 {
width: calc(var(--boxSize) * 8);
}
div.c_0 {
left: 0px;
}
div.c_1 {
left: var(--boxSize);
}
div.c_2 {
left: calc(var(--boxSize) * 2);
}
div.c_3 {
left: calc(var(--boxSize) * 3);
}
div.c_4 {
left: calc(var(--boxSize) * 4);
}
div.c_5 {
left: calc(var(--boxSize) * 5);
}
div.c_6 {
left: calc(var(--boxSize) * 6);
}
div.c_7 {
left: calc(var(--boxSize) * 7);
}
div.c_8 {
left: calc(var(--boxSize) * 8);
}
div.r_0 {
top: 0px;
}
div.r_1 {
top: var(--boxSize);
}
div.r_2 {
top: calc(var(--boxSize) * 2);
}
div.r_3 {
top: calc(var(--boxSize) * 3);
}
div.r_4 {
top: calc(var(--boxSize) * 4);
}
div.r_5 {
top: calc(var(--boxSize) * 5);
}
div.r_6 {
top: calc(var(--boxSize) * 6);
}
div.r_7 {
top: calc(var(--boxSize) * 7);
}
div.r_8 {
top: calc(var(--boxSize) * 8);
}
div.r_9 {
top: calc(var(--boxSize) * 9);
}
div.r_10 {
top: calc(var(--boxSize) * 10);
}
div.r_11 {
top: calc(var(--boxSize) * 11);
}
div.r_12 {
top: calc(var(--boxSize) * 12);
}
div.r_13 {
top: calc(var(--boxSize) * 13);
}
div.r_14 {
top: calc(var(--boxSize) * 14);
}
div.r_15 {
top: calc(var(--boxSize) * 15);
}

View File

@@ -0,0 +1,18 @@
@inherits Microsoft.AspNetCore.Components.Forms.InputBase<MUnique.OpenMU.DataModel.Entities.ItemStorage>
@if (this._viewModel is null)
{
return;
}
<div class=@($"mu-item-storage storage-rows{this._viewModel.Rows}")>
@foreach (var item in this._viewModel.Items)
{
<MuItem
Model="item"
OnClick="async () => await this.SetSelectedItemAsync(item)"
OnItemMoved="this.OnItemMovedAsync"
OnDelete="this.OnDelete"
IsSelected="this.SelectedItem == item.Item"/>
}
</div>

View File

@@ -0,0 +1,102 @@
// <copyright file="MuItemStorage.razor.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
using Microsoft.AspNetCore.Components;
/// <summary>
/// Component for a item box.
/// </summary>
public partial class MuItemStorage
{
private StorageViewModel? _viewModel;
private Item? _selectedItem;
/// <summary>
/// Gets or sets the selected item.
/// </summary>
[Parameter]
public Item? SelectedItem
{
get => this._selectedItem;
set
{
if (this._selectedItem == value)
{
return;
}
this._selectedItem = value;
}
}
/// <summary>
/// Gets or sets the type of the storage.
/// </summary>
[Parameter]
public StorageType StorageType { get; set; }
/// <summary>
/// Gets or sets the number of extensions.
/// </summary>
[Parameter]
public byte NumberOfExtensions { get; set; }
/// <summary>
/// Gets or sets the index of the extension.
/// </summary>
[Parameter]
public byte ExtensionIndex { get; set; }
/// <summary>
/// Gets or sets the callback when the selected item changed.
/// </summary>
[Parameter]
public EventCallback<Item?> SelectedItemChanged { get; set; }
/// <summary>
/// Gets or sets the callback when an item should be deleted.
/// </summary>
[Parameter]
public EventCallback OnDelete { get; set; }
/// <summary>
/// Sets the selected item.
/// </summary>
/// <param name="viewModel">The view model.</param>
public async Task SetSelectedItemAsync(ItemViewModel viewModel)
{
if (this.SelectedItemChanged.HasDelegate)
{
await this.SelectedItemChanged.InvokeAsync(viewModel.Item).ConfigureAwait(true);
}
}
/// <inheritdoc />
protected override void OnParametersSet()
{
base.OnParametersSet();
if (this.Value is { } value)
{
this._viewModel = value.CreateViewModel(this.StorageType, this.NumberOfExtensions, this.ExtensionIndex);
}
}
/// <inheritdoc />
protected override bool TryParseValueFromString(string? value, out ItemStorage result, out string validationErrorMessage)
{
result = null!;
validationErrorMessage = string.Empty;
return false;
}
private async Task OnItemMovedAsync()
{
if (this.SelectedItemChanged.HasDelegate)
{
await this.SelectedItemChanged.InvokeAsync(this.SelectedItem).ConfigureAwait(true);
}
}
}

View File

@@ -0,0 +1,22 @@

.mu-item-storage {
--boxSize: 42px;
--storageColumns: 8;
background-image: url('/_content/MUnique.OpenMU.Web.ItemEditor/img/inventory_back.png');
background-size: 12.5%;
width: calc(var(--boxSize) * (var(--storageColumns)));
position: relative;
display: flex;
}
.storage-rows4 {
height: calc(var(--boxSize) * 4);
}
.storage-rows8 {
height: calc(var(--boxSize) * 8);
}
.storage-rows15 {
height: calc(var(--boxSize) * 15);
}

View File

@@ -0,0 +1,41 @@
// <copyright file="StorageType.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
/// <summary>
/// Defines the storage type.
/// </summary>
public enum StorageType
{
/// <summary>
/// The inventory.
/// </summary>
Inventory,
/// <summary>
/// The inventory extension.
/// </summary>
InventoryExtension,
/// <summary>
/// The personal store.
/// </summary>
PersonalStore,
/// <summary>
/// The vault.
/// </summary>
Vault,
/// <summary>
/// The vault extension.
/// </summary>
VaultExtension,
/// <summary>
/// The merchant.
/// </summary>
Merchant,
}

View File

@@ -0,0 +1,89 @@
// <copyright file="StorageViewModel.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
/// <summary>
/// View Model for the <see cref="ItemStorage"/>.
/// </summary>
public class StorageViewModel
{
/// <summary>
/// Initializes a new instance of the <see cref="StorageViewModel" /> class.
/// </summary>
/// <param name="storage">The storage.</param>
/// <param name="storageType">Type of the storage.</param>
/// <param name="rows">The rows.</param>
/// <param name="startIndex">The start index.</param>
/// <param name="endIndex">The end index.</param>
/// <param name="emptyRowsToNextStorage">The empty rows to next storage.</param>
/// <param name="emptyRowsToPreviousStorage">The empty rows to previous storage.</param>
public StorageViewModel(ItemStorage storage, StorageType storageType, int rows, int startIndex, int endIndex, byte? emptyRowsToNextStorage = null, byte? emptyRowsToPreviousStorage = null)
{
if (rows is not (4 or 8 or 15))
{
throw new ArgumentException($"Unsupported number of rows ({rows}) instead of 4, 8 or 15.", nameof(rows));
}
this.Storage = storage;
this.StorageType = storageType;
this.Rows = rows;
this.StartIndex = startIndex;
this.EndIndex = endIndex;
this.EmptyRowsToNextStorage = emptyRowsToNextStorage;
this.EmptyRowsToPreviousStorage = emptyRowsToPreviousStorage;
}
/// <summary>
/// Gets the storage.
/// </summary>
public ItemStorage Storage { get; }
/// <summary>
/// Gets the type of the storage.
/// </summary>
public StorageType StorageType { get; }
/// <summary>
/// Gets the items.
/// </summary>
public IEnumerable<ItemViewModel> Items => this.Storage.Items.Where(this.IsIncluded).Select(item => new ItemViewModel(this, item));
/// <summary>
/// Gets the rows.
/// </summary>
public int Rows { get; }
/// <summary>
/// Gets the start index.
/// </summary>
public int StartIndex { get; }
/// <summary>
/// Gets the end index.
/// </summary>
public int EndIndex { get; }
/// <summary>
/// Gets the empty rows to next storage.
/// </summary>
public byte? EmptyRowsToNextStorage { get; }
/// <summary>
/// Gets the empty rows to previous storage.
/// </summary>
public byte? EmptyRowsToPreviousStorage { get; }
/// <summary>
/// Determines whether the specified item is included in the shown storage box.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is included; otherwise, <c>false</c>.
/// </returns>
public bool IsIncluded(Item item)
{
return item.ItemSlot >= this.StartIndex && item.ItemSlot <= this.EndIndex;
}
}

View File

@@ -0,0 +1,83 @@
// <copyright file="StorageViewModelFactory.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Web.ItemEditor;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Entities;
/// <summary>
/// Factory for <see cref="StorageViewModel"/>s.
/// </summary>
internal static class StorageViewModelFactory
{
/// <summary>
/// Creates the view model.
/// </summary>
/// <param name="storage">The storage.</param>
/// <param name="storageType">Type of the storage.</param>
/// <param name="extensions">The storage extensions.</param>
/// <param name="extensionIndex">The storage extension index.</param>
/// <returns>The created view model.</returns>
internal static StorageViewModel CreateViewModel(this ItemStorage storage, StorageType storageType, byte extensions = 0, byte extensionIndex = 0)
{
switch (storageType)
{
case StorageType.Inventory:
{
var emptyRowsToPersonalStore = (byte)((InventoryConstants.MaximumNumberOfExtensions - extensions) * InventoryConstants.RowsOfOneExtension);
var emptyRowsToNextStorage = extensions == 0 ? emptyRowsToPersonalStore : (byte)0;
return new StorageViewModel(
storage,
storageType,
InventoryConstants.InventoryRows,
InventoryConstants.EquippableSlotsCount,
(byte)(InventoryConstants.GetInventorySize(0) - 1),
emptyRowsToNextStorage);
}
case StorageType.InventoryExtension:
{
var emptyRowsToPersonalStore = (byte)((InventoryConstants.MaximumNumberOfExtensions - extensions) * InventoryConstants.RowsOfOneExtension);
var emptyRowsToNextStorage = extensions > (extensionIndex + 1) ? (byte)0 : emptyRowsToPersonalStore;
var startIndex = InventoryConstants.FirstExtensionItemSlotIndex + (extensionIndex * InventoryConstants.RowsOfOneExtension * InventoryConstants.RowSize);
return new StorageViewModel(
storage,
storageType,
InventoryConstants.RowsOfOneExtension,
startIndex,
(byte)(InventoryConstants.GetInventorySize(extensionIndex + 1) - 1),
emptyRowsToNextStorage);
}
case StorageType.PersonalStore:
var emptyRowsToInventory = (byte)((InventoryConstants.MaximumNumberOfExtensions - extensions) * InventoryConstants.RowsOfOneExtension);
return new StorageViewModel(
storage,
storageType,
InventoryConstants.StoreRows,
InventoryConstants.FirstStoreItemSlotIndex,
(byte)(InventoryConstants.FirstStoreItemSlotIndex + InventoryConstants.StoreSize),
null,
emptyRowsToInventory);
case StorageType.VaultExtension:
return new StorageViewModel(
storage,
storageType,
InventoryConstants.WarehouseRows,
InventoryConstants.WarehouseSize,
(InventoryConstants.WarehouseSize * 2) - 1);
case StorageType.Vault:
case StorageType.Merchant:
return new StorageViewModel(
storage,
storageType,
InventoryConstants.WarehouseRows,
0,
InventoryConstants.WarehouseSize - 1);
default:
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1 @@
@using Microsoft.AspNetCore.Components.Web

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More