diff --git a/src/Dapr/GameServer.Host/Layout/MainLayout.razor b/src/Dapr/GameServer.Host/Layout/MainLayout.razor index 919fd3e..6d98d8a 100644 --- a/src/Dapr/GameServer.Host/Layout/MainLayout.razor +++ b/src/Dapr/GameServer.Host/Layout/MainLayout.razor @@ -1,12 +1,6 @@ @inherits LayoutComponentBase
-
diff --git a/src/Dapr/GameServer.Host/_Imports.razor b/src/Dapr/GameServer.Host/_Imports.razor index 4a18a36..61d3ed5 100644 --- a/src/Dapr/GameServer.Host/_Imports.razor +++ b/src/Dapr/GameServer.Host/_Imports.razor @@ -9,9 +9,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.Shared diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 91b3051..5c2708a 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,6 @@ - diff --git a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs index ff16a74..459443d 100644 --- a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs +++ b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs @@ -92,69 +92,6 @@ internal class EntityFrameworkContextBase : IContext } } - /// - /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation - /// racing this save, and is therefore worth retrying. - /// - /// The exception thrown by the save. - /// true if the save should be retried. - private static bool IsTransientConcurrencyConflict(Exception exception) - { - // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. - // Depending on exactly where change detection was, it surfaces as one of several types - a - // modified collection (InvalidOperationException), a transiently-null internal key - // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: - // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. - // A genuinely persistent error of the same type is not masked - it rethrows once the retries - // are exhausted. The deterministic serialization (per-player persistence lock) is the primary - // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. - return exception is DbUpdateConcurrencyException - or InvalidOperationException - or ArgumentNullException - or NullReferenceException - or IndexOutOfRangeException - or KeyNotFoundException; - } - - private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) - { - using var l = await this._lock.LockAsync(); - - // when we have a change publisher attached, we want to get the changed entries before accepting them. - // Otherwise, we can accept them. - var acceptChanges = true; - - object? sender = null; - SavedChangesEventArgs? args = null; - if (this._changeListener is { }) - { - this.Context.SavedChanges += OnSavedChanges; - acceptChanges = false; - } - - try - { - await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); - - if (args is not null) - { - await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); - } - } - finally - { - this.Context.SavedChanges -= OnSavedChanges; - } - - return true; - - void OnSavedChanges(object? s, SavedChangesEventArgs e) - { - sender = s; - args = e; - } - } - /// public IDisposable SuspendChangeNotifications() { @@ -323,6 +260,69 @@ internal class EntityFrameworkContextBase : IContext this.Context.Dispose(); } + /// + /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation + /// racing this save, and is therefore worth retrying. + /// + /// The exception thrown by the save. + /// true if the save should be retried. + private static bool IsTransientConcurrencyConflict(Exception exception) + { + // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration. + // Depending on exactly where change detection was, it surfaces as one of several types - a + // modified collection (InvalidOperationException), a transiently-null internal key + // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient: + // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment. + // A genuinely persistent error of the same type is not masked - it rethrows once the retries + // are exhausted. The deterministic serialization (per-player persistence lock) is the primary + // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for. + return exception is DbUpdateConcurrencyException + or InvalidOperationException + or ArgumentNullException + or NullReferenceException + or IndexOutOfRangeException + or KeyNotFoundException; + } + + private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken) + { + using var l = await this._lock.LockAsync(); + + // when we have a change publisher attached, we want to get the changed entries before accepting them. + // Otherwise, we can accept them. + var acceptChanges = true; + + object? sender = null; + SavedChangesEventArgs? args = null; + if (this._changeListener is { }) + { + this.Context.SavedChanges += OnSavedChanges; + acceptChanges = false; + } + + try + { + await this.Context.SaveChangesAsync(acceptChanges, cancellationToken).ConfigureAwait(false); + + if (args is not null) + { + await this.OnSavedChangesAsync(sender, args).ConfigureAwait(false); + } + } + finally + { + this.Context.SavedChanges -= OnSavedChanges; + } + + return true; + + void OnSavedChanges(object? s, SavedChangesEventArgs e) + { + sender = s; + args = e; + } + } + private bool DetachInternal(object item) { var entry = this.Context.Entry(item); diff --git a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs index d1bceb0..d40faa7 100644 --- a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs +++ b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.cs @@ -34,7 +34,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations TaxStore = table.Column(type: "smallint", nullable: false), TaxHunt = table.Column(type: "integer", nullable: false), IsHuntZoneEnabled = table.Column(type: "boolean", nullable: false), - TributeMoney = table.Column(type: "bigint", nullable: false) + TributeMoney = table.Column(type: "bigint", nullable: false), }, constraints: table => { @@ -53,7 +53,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefenseLevel = table.Column(type: "smallint", nullable: false), RegenLevel = table.Column(type: "smallint", nullable: false), LifeLevel = table.Column(type: "smallint", nullable: false), - CurrentHp = table.Column(type: "integer", nullable: false) + CurrentHp = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -87,7 +87,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations GuildScoreCastleSiege = table.Column(type: "integer", nullable: false), GuildScoreCastleSiegeMembers = table.Column(type: "integer", nullable: false), GateBuyPrice = table.Column(type: "integer", nullable: false), - StatueBuyPrice = table.Column(type: "integer", nullable: false) + StatueBuyPrice = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -125,7 +125,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations DefaultSide = table.Column(type: "smallint", nullable: false), SpawnX = table.Column(type: "smallint", nullable: false), SpawnY = table.Column(type: "smallint", nullable: false), - Direction = table.Column(type: "integer", nullable: false) + Direction = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -155,7 +155,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations State = table.Column(type: "smallint", nullable: false), DayOfWeek = table.Column(type: "integer", nullable: false), Hour = table.Column(type: "smallint", nullable: false), - Minute = table.Column(type: "smallint", nullable: false) + Minute = table.Column(type: "smallint", nullable: false), }, constraints: table => { @@ -183,7 +183,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations Level = table.Column(type: "smallint", nullable: false), RequiredJewelOfGuardianCount = table.Column(type: "integer", nullable: false), RequiredZen = table.Column(type: "integer", nullable: false), - Value = table.Column(type: "integer", nullable: false) + Value = table.Column(type: "integer", nullable: false), }, constraints: table => { @@ -236,7 +236,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations X1 = table.Column(type: "smallint", nullable: false), Y1 = table.Column(type: "smallint", nullable: false), X2 = table.Column(type: "smallint", nullable: false), - Y2 = table.Column(type: "smallint", nullable: false) + Y2 = table.Column(type: "smallint", nullable: false), }, constraints: table => { diff --git a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor index 855e43d..e6dad5d 100644 --- a/src/Web/AdminPanel/Components/Layout/CreationPanel.razor +++ b/src/Web/AdminPanel/Components/Layout/CreationPanel.razor @@ -8,7 +8,7 @@ @implements IDisposable @inject CreationPanelService Panel -@inject Blazored.Toast.Services.IToastService ToastService +@inject IToastService ToastService @if (this.Panel.Current is { } session) { diff --git a/src/Web/AdminPanel/Components/Layout/MainLayout.razor b/src/Web/AdminPanel/Components/Layout/MainLayout.razor index 429c724..ff9f4f4 100644 --- a/src/Web/AdminPanel/Components/Layout/MainLayout.razor +++ b/src/Web/AdminPanel/Components/Layout/MainLayout.razor @@ -39,7 +39,7 @@
- +
@Body diff --git a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj index 276cb71..a974a32 100644 --- a/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj +++ b/src/Web/AdminPanel/MUnique.OpenMU.Web.AdminPanel.csproj @@ -25,7 +25,6 @@ - diff --git a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs index 48c4497..0489c52 100644 --- a/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateConnectServerConfig.razor.cs @@ -6,12 +6,12 @@ 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.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs index a5597d6..b7a45f9 100644 --- a/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs +++ b/src/Web/AdminPanel/Pages/CreateGameServerConfig.razor.cs @@ -6,13 +6,13 @@ 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; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditBase.cs b/src/Web/AdminPanel/Pages/EditBase.cs index d180a72..f5cc8bd 100644 --- a/src/Web/AdminPanel/Pages/EditBase.cs +++ b/src/Web/AdminPanel/Pages/EditBase.cs @@ -6,7 +6,6 @@ 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; @@ -19,6 +18,7 @@ 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.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs index 5479006..e95056e 100644 --- a/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs +++ b/src/Web/AdminPanel/Pages/EditConfigGrid.razor.cs @@ -8,7 +8,6 @@ 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; @@ -18,6 +17,7 @@ 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.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/EditMap.cs b/src/Web/AdminPanel/Pages/EditMap.cs index 477a1d9..9700e16 100644 --- a/src/Web/AdminPanel/Pages/EditMap.cs +++ b/src/Web/AdminPanel/Pages/EditMap.cs @@ -6,7 +6,6 @@ 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; @@ -19,6 +18,7 @@ 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.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Merchants.razor.cs b/src/Web/AdminPanel/Pages/Merchants.razor.cs index 76bd696..62728f6 100644 --- a/src/Web/AdminPanel/Pages/Merchants.razor.cs +++ b/src/Web/AdminPanel/Pages/Merchants.razor.cs @@ -6,7 +6,6 @@ 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; @@ -16,6 +15,7 @@ using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.Toast; using MUnique.OpenMU.Web.Shared.Services; /// diff --git a/src/Web/AdminPanel/Pages/Servers.razor b/src/Web/AdminPanel/Pages/Servers.razor index 0c1c70b..595ca2d 100644 --- a/src/Web/AdminPanel/Pages/Servers.razor +++ b/src/Web/AdminPanel/Pages/Servers.razor @@ -2,7 +2,6 @@ @using System.ComponentModel @using Microsoft.Extensions.DependencyInjection -@using Blazored.Toast.Services @using MUnique.OpenMU.Interfaces @using MUnique.OpenMU.Web.AdminPanel.Properties diff --git a/src/Web/AdminPanel/Startup.cs b/src/Web/AdminPanel/Startup.cs index ffe0edc..8c870da 100644 --- a/src/Web/AdminPanel/Startup.cs +++ b/src/Web/AdminPanel/Startup.cs @@ -5,7 +5,6 @@ namespace MUnique.OpenMU.Web.AdminPanel; using System.IO; -using Blazored.Toast; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -61,7 +60,7 @@ public class Startup .ConfigureApplicationPartManager(setup => setup.FeatureProviders.Add(new GenericControllerFeatureProvider())); - services.AddBlazoredToast(); + services.AddToasts(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/Web/AdminPanel/WebApplicationExtensions.cs b/src/Web/AdminPanel/WebApplicationExtensions.cs index 32b9159..16c5908 100644 --- a/src/Web/AdminPanel/WebApplicationExtensions.cs +++ b/src/Web/AdminPanel/WebApplicationExtensions.cs @@ -5,7 +5,6 @@ namespace MUnique.OpenMU.Web.AdminPanel; using System.IO; -using Blazored.Toast; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.StaticWebAssets; using Microsoft.Extensions.DependencyInjection; @@ -66,7 +65,7 @@ public static class WebApplicationExtensions .ConfigureApplicationPartManager(setup => setup.FeatureProviders.Add(new GenericControllerFeatureProvider())); - services.AddBlazoredToast(); + services.AddToasts(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/src/Web/AdminPanel/_Imports.razor b/src/Web/AdminPanel/_Imports.razor index a3b424c..427cefe 100644 --- a/src/Web/AdminPanel/_Imports.razor +++ b/src/Web/AdminPanel/_Imports.razor @@ -10,9 +10,6 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.AdminPanel @@ -20,6 +17,7 @@ @using MUnique.OpenMU.Web.Shared @using MUnique.OpenMU.Web.Shared.Components +@using MUnique.OpenMU.Web.Shared.Components.Toast @using MUnique.OpenMU.Web.Shared.Components.Modal @using MUnique.OpenMU.Web.Shared.Components.Form @using MUnique.OpenMU.Web.Shared.Components.Form.Modal diff --git a/src/Web/Shared/Components/Toast/IToastService.cs b/src/Web/Shared/Components/Toast/IToastService.cs new file mode 100644 index 0000000..0b1de95 --- /dev/null +++ b/src/Web/Shared/Components/Toast/IToastService.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +using System; +using System.Collections.Generic; + +/// +/// Service for showing toast notifications. +/// +public interface IToastService +{ + /// + /// Occurs when the list of toasts has changed (added, closed, cleared). + /// + event Action? StateChanged; + + /// + /// Gets the currently shown toasts. + /// + IReadOnlyList Toasts { get; } + + /// + /// Shows a success toast. + /// + /// The message. + /// The optional heading. + void ShowSuccess(string message, string? heading = null); + + /// + /// Shows an info toast. + /// + /// The message. + /// The optional heading. + void ShowInfo(string message, string? heading = null); + + /// + /// Shows a warning toast. + /// + /// The message. + /// The optional heading. + void ShowWarning(string message, string? heading = null); + + /// + /// Shows an error toast. + /// + /// The message. + /// The optional heading. + void ShowError(string message, string? heading = null); + + /// + /// Closes the specified toast (triggers its closing animation). + /// + /// The toast to close. + void Close(ToastInstance toast); + + /// + /// Closes all currently shown toasts. + /// + void Clear(); +} diff --git a/src/Web/Shared/Components/Toast/ToastContainer.razor b/src/Web/Shared/Components/Toast/ToastContainer.razor new file mode 100644 index 0000000..907f5c3 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastContainer.razor @@ -0,0 +1,66 @@ +@using MUnique.OpenMU.Web.Shared.Components.Toast +@using MUnique.OpenMU.Web.Shared.Services +@inject ToastService ToastService +@implements IDisposable + +@if (this.ToastService.Toasts.Count > 0) +{ +
+ @foreach (var toast in this.ToastService.Toasts) + { + var (iconClass, accentClass) = this.GetStyling(toast.Level); + + } +
+} + +@code { + /// + protected override void OnInitialized() + { + this.ToastService.StateChanged += this.OnStateChanged; + } + + /// + public void Dispose() + { + this.ToastService.StateChanged -= this.OnStateChanged; + } + + private void OnStateChanged() + { + _ = this.InvokeAsync(this.StateHasChanged); + } + + private Task CloseAsync(ToastInstance toast) + { + this.ToastService.Close(toast); + return Task.CompletedTask; + } + + private (string IconClass, string AccentClass) GetStyling(ToastLevel level) + { + return level switch + { + ToastLevel.Success => ("oi-circle-check", "text-success"), + ToastLevel.Info => ("oi-info", "text-primary"), + ToastLevel.Warning => ("oi-warning", "text-warning"), + ToastLevel.Error => ("oi-bolt", "text-danger"), + _ => ("oi-info", "text-primary"), + }; + } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastContainer.razor.css b/src/Web/Shared/Components/Toast/ToastContainer.razor.css new file mode 100644 index 0000000..dc26200 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastContainer.razor.css @@ -0,0 +1,34 @@ +.toast-container { + z-index: 1080; +} + +.toast { + min-width: 18rem; + border-left: 4px solid currentColor; + background-color: var(--bs-body-bg); + box-shadow: var(--bs-box-shadow-lg); + animation: toast-slide-in 0.2s ease-out; + opacity: 1; + transition: opacity 0.3s ease; +} + +.toast.closing { + opacity: 0; +} + +.toast__icon { + font-size: 1.1rem; + line-height: 1; + align-self: center; +} + +@keyframes toast-slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastInstance.cs b/src/Web/Shared/Components/Toast/ToastInstance.cs new file mode 100644 index 0000000..0fb8cca --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastInstance.cs @@ -0,0 +1,52 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +using System; + +/// +/// Represents a single toast message shown in the . +/// +public sealed class ToastInstance +{ + /// + /// Initializes a new instance of the class. + /// + /// The level. + /// The message. + /// The optional heading. + internal ToastInstance(ToastLevel level, string message, string? heading) + { + this.Key = Guid.NewGuid(); + this.Level = level; + this.Message = message; + this.Heading = heading; + } + + /// + /// Gets a stable key identifying this toast, used as a render key. + /// + public Guid Key { get; } + + /// + /// Gets the level. + /// + public ToastLevel Level { get; } + + /// + /// Gets the message. + /// + public string Message { get; } + + /// + /// Gets the optional heading. + /// + public string? Heading { get; } + + /// + /// Gets or sets a value indicating whether the toast is performing its closing animation. + /// + internal bool IsClosing { get; set; } +} \ No newline at end of file diff --git a/src/Web/Shared/Components/Toast/ToastLevel.cs b/src/Web/Shared/Components/Toast/ToastLevel.cs new file mode 100644 index 0000000..269d160 --- /dev/null +++ b/src/Web/Shared/Components/Toast/ToastLevel.cs @@ -0,0 +1,31 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// The level of a toast message. +/// +public enum ToastLevel +{ + /// + /// Informational message. + /// + Info, + + /// + /// Success message. + /// + Success, + + /// + /// Warning message. + /// + Warning, + + /// + /// Error message. + /// + Error, +} diff --git a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj index bc4006e..ee5fe23 100644 --- a/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj +++ b/src/Web/Shared/MUnique.OpenMU.Web.Shared.csproj @@ -52,7 +52,6 @@ - diff --git a/src/Web/Shared/Services/ToastService.cs b/src/Web/Shared/Services/ToastService.cs new file mode 100644 index 0000000..c01ded1 --- /dev/null +++ b/src/Web/Shared/Services/ToastService.cs @@ -0,0 +1,172 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Default implementation of . +/// +public sealed class ToastService : IToastService, IDisposable +{ + private static readonly TimeSpan DefaultDuration = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ClosingDuration = TimeSpan.FromMilliseconds(300); + + private readonly object _lock = new(); + private readonly List _toasts = new(); + private readonly List _cancellations = new(); + + /// + public event Action? StateChanged; + + /// + public IReadOnlyList Toasts + { + get + { + lock (this._lock) + { + return this._toasts.ToArray(); + } + } + } + + /// + public void ShowSuccess(string message, string? heading = null) + { + this.Show(ToastLevel.Success, message, heading); + } + + /// + public void ShowInfo(string message, string? heading = null) + { + this.Show(ToastLevel.Info, message, heading); + } + + /// + public void ShowWarning(string message, string? heading = null) + { + this.Show(ToastLevel.Warning, message, heading); + } + + /// + public void ShowError(string message, string? heading = null) + { + this.Show(ToastLevel.Error, message, heading); + } + + /// + public void Close(ToastInstance toast) + { + this.StartClosing(toast); + } + + /// + public void Clear() + { + lock (this._lock) + { + foreach (var cts in this._cancellations) + { + cts.Cancel(); + } + + this._cancellations.Clear(); + this._toasts.Clear(); + } + + this.StateChanged?.Invoke(); + } + + /// + public void Dispose() + { + lock (this._lock) + { + foreach (var cts in this._cancellations) + { + cts.Dispose(); + } + + this._cancellations.Clear(); + this._toasts.Clear(); + } + } + + private void Show(ToastLevel level, string message, string? heading) + { + var toast = new ToastInstance(level, message, heading); + var cts = new CancellationTokenSource(); + lock (this._lock) + { + this._toasts.Add(toast); + this._cancellations.Add(cts); + } + + this.StateChanged?.Invoke(); + + _ = this.AutoCloseAsync(toast, cts); + } + + private async Task AutoCloseAsync(ToastInstance toast, CancellationTokenSource cts) + { + try + { + await Task.Delay(DefaultDuration, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + this.StartClosing(toast); + } + + private void StartClosing(ToastInstance toast) + { + lock (this._lock) + { + var index = this._toasts.IndexOf(toast); + if (index < 0 || toast.IsClosing) + { + return; + } + + toast.IsClosing = true; + this._cancellations[index].Cancel(); + } + + this.StateChanged?.Invoke(); + + _ = this.FinishClosingAsync(toast); + } + + private Task FinishClosingAsync(ToastInstance toast) + { + return Task.Run(async () => + { + await Task.Delay(ClosingDuration).ConfigureAwait(false); + + lock (this._lock) + { + var index = this._toasts.IndexOf(toast); + if (index < 0) + { + return; + } + + this._cancellations[index].Dispose(); + this._cancellations.RemoveAt(index); + this._toasts.RemoveAt(index); + } + + this.StateChanged?.Invoke(); + }); + } +} \ No newline at end of file diff --git a/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs b/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs new file mode 100644 index 0000000..1e15a25 --- /dev/null +++ b/src/Web/Shared/Services/ToastServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Services; + +using Microsoft.Extensions.DependencyInjection; +using MUnique.OpenMU.Web.Shared.Components.Toast; + +/// +/// Extension methods for registering the toast service. +/// +public static class ToastServiceCollectionExtensions +{ + /// + /// Adds the toast service to the service collection. + /// + /// The service collection. + /// The service collection, for chaining. + public static IServiceCollection AddToasts(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + return services; + } +} \ No newline at end of file diff --git a/src/Web/Shared/_Imports.razor b/src/Web/Shared/_Imports.razor index 761ce4e..1ff3a52 100644 --- a/src/Web/Shared/_Imports.razor +++ b/src/Web/Shared/_Imports.razor @@ -6,9 +6,6 @@ @using Microsoft.Extensions.Localization @using Microsoft.JSInterop -@using Blazored.Toast -@using Blazored.Toast.Services - @using BlazorInputFile @using MUnique.OpenMU.Web.Shared