baseline: OpenMU upstream b5a0961 (fresh source)
104
src/Web/AdminPanel/API/ServerController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Web/AdminPanel/AdminPanelEnvironment.cs
Normal 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; }
|
||||
}
|
||||
67
src/Web/AdminPanel/Components/App.razor
Normal 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>
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
72
src/Web/AdminPanel/Components/Install.razor
Normal 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>
|
||||
108
src/Web/AdminPanel/Components/Install.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor
Normal 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>
|
||||
32
src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
72
src/Web/AdminPanel/Components/Layout/CreationPanel.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
78
src/Web/AdminPanel/Components/Layout/CreationPanel.razor.css
Normal 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;
|
||||
}
|
||||
57
src/Web/AdminPanel/Components/Layout/MainLayout.razor
Normal 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 />
|
||||
80
src/Web/AdminPanel/Components/Layout/MainLayout.razor.css
Normal 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;
|
||||
}
|
||||
114
src/Web/AdminPanel/Components/Layout/NavMenu.razor
Normal 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>
|
||||
147
src/Web/AdminPanel/Components/Layout/NavMenu.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/Web/AdminPanel/Components/Layout/NavMenu.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/Web/AdminPanel/Components/Layout/ReconnectModal.razor
Normal 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>
|
||||
157
src/Web/AdminPanel/Components/Layout/ReconnectModal.razor.css
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/Web/AdminPanel/Components/Layout/ReconnectModal.razor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
8
src/Web/AdminPanel/Components/Routes.razor
Normal 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>
|
||||
232
src/Web/AdminPanel/Components/ServerItem.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/Web/AdminPanel/Components/TotalOnlineCounter.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/Web/AdminPanel/Exports.cs
Normal 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 => [];
|
||||
}
|
||||
79
src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj
Normal 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->Distinct())" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
39
src/Web/AdminPanel/Pages/Accounts.razor
Normal 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>
|
||||
47
src/Web/AdminPanel/Pages/AdminUsers.razor
Normal 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>
|
||||
28
src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor
Normal 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>
|
||||
|
||||
209
src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
28
src/Web/AdminPanel/Pages/CreateGameServerConfig.razor
Normal 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>
|
||||
|
||||
245
src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
63
src/Web/AdminPanel/Pages/EditAccount.razor.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
435
src/Web/AdminPanel/Pages/EditBase.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/Web/AdminPanel/Pages/EditConfig.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
54
src/Web/AdminPanel/Pages/EditConfigGrid.razor
Normal 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>
|
||||
486
src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
39
src/Web/AdminPanel/Pages/EditConfigGrid.razor.css
Normal 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;
|
||||
}
|
||||
29
src/Web/AdminPanel/Pages/EditConnectionServer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
288
src/Web/AdminPanel/Pages/EditMap.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
14
src/Web/AdminPanel/Pages/Error.razor
Normal 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>
|
||||
57
src/Web/AdminPanel/Pages/GameServer.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
9
src/Web/AdminPanel/Pages/Index.razor
Normal 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
|
||||
57
src/Web/AdminPanel/Pages/LogFiles.razor
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
54
src/Web/AdminPanel/Pages/LoggedIn.razor
Normal 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>
|
||||
194
src/Web/AdminPanel/Pages/MapPage.razor
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/Web/AdminPanel/Pages/Merchants.razor
Normal 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">< @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>
|
||||
}
|
||||
258
src/Web/AdminPanel/Pages/Merchants.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/Web/AdminPanel/Pages/NotFound.razor
Normal 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>
|
||||
54
src/Web/AdminPanel/Pages/Plugins.razor
Normal 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>
|
||||
60
src/Web/AdminPanel/Pages/Plugins.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
236
src/Web/AdminPanel/Pages/Servers.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Web/AdminPanel/Pages/Setup.razor
Normal 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>
|
||||
}
|
||||
68
src/Web/AdminPanel/Pages/Setup.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/Web/AdminPanel/Pages/Updates.razor
Normal 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> @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> @exception.Message</span>
|
||||
</h3>
|
||||
<hr/>
|
||||
<span>@exception.StackTrace</span>
|
||||
</div>
|
||||
}
|
||||
182
src/Web/AdminPanel/Pages/Updates.razor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/Web/AdminPanel/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1542
src/Web/AdminPanel/Properties/Resources.Designer.cs
generated
Normal file
615
src/Web/AdminPanel/Properties/Resources.resx
Normal 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><strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.</value>
|
||||
</data>
|
||||
<data name="SwappingToDevForMoreInformation" xml:space="preserve">
|
||||
<value>Swapping to <strong>Development</strong> 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>
|
||||
91
src/Web/AdminPanel/Readme.md
Normal 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.
|
||||
20
src/Web/AdminPanel/Services/ConfigurationSearchEntry.cs
Normal 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);
|
||||
166
src/Web/AdminPanel/Services/ConfigurationSearchIndexCache.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
402
src/Web/AdminPanel/Services/ConfigurationSearchIndexer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
138
src/Web/AdminPanel/Services/SetupService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/Web/AdminPanel/Startup.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
136
src/Web/AdminPanel/WebApplicationExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/Web/AdminPanel/_Imports.razor
Normal 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
|
||||
17
src/Web/ItemEditor/EquippedItems.razor
Normal 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>
|
||||
81
src/Web/ItemEditor/EquippedItems.razor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/Web/ItemEditor/EquippedItems.razor.css
Normal 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;
|
||||
}
|
||||
14
src/Web/ItemEditor/GlobalUsings.cs
Normal 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
|
||||
154
src/Web/ItemEditor/InventoryPlacementService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/Web/ItemEditor/ItemExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/Web/ItemEditor/ItemViewModel.cs
Normal 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;
|
||||
}
|
||||
37
src/Web/ItemEditor/MUnique.OpenMU.Web.ItemEditor.csproj
Normal 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>
|
||||
78
src/Web/ItemEditor/MuItem.razor
Normal 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>
|
||||
}
|
||||
155
src/Web/ItemEditor/MuItem.razor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
289
src/Web/ItemEditor/MuItem.razor.css
Normal 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);
|
||||
}
|
||||
18
src/Web/ItemEditor/MuItemStorage.razor
Normal 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>
|
||||
102
src/Web/ItemEditor/MuItemStorage.razor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/Web/ItemEditor/MuItemStorage.razor.css
Normal 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);
|
||||
}
|
||||
41
src/Web/ItemEditor/StorageType.cs
Normal 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,
|
||||
}
|
||||
89
src/Web/ItemEditor/StorageViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
83
src/Web/ItemEditor/StorageViewModelFactory.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/Web/ItemEditor/_Imports.razor
Normal file
@@ -0,0 +1 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
BIN
src/Web/ItemEditor/wwwroot/img/equipment_back.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/inventory_back.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_0.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_0_e.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_11.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_11_e.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_13.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_13_e.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_15.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_15_e.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_3.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_3_e.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/Web/ItemEditor/wwwroot/img/items/item_0_0_5.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |