diff --git a/CleanAspire.slnx b/CleanAspire.slnx index 14616e7..f4b9f32 100644 --- a/CleanAspire.slnx +++ b/CleanAspire.slnx @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index a61388e..2a1ff1b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,19 @@ With a focus on **Clean Architecture** and **extreme code simplicity**, CleanAspire provides developers with the tools to create responsive and maintainable web applications with minimal effort. The template also supports **Microsoft.Kiota** to simplify API client generation, ensuring consistency and productivity in every project. +### 🌐 Offline Support + +CleanAspire fully supports **offline mode** through its integrated PWA capabilities, enabling your application to function seamlessly without an internet connection. By leveraging **Service Workers** and **browser caching**, the application can store essential resources and data locally, ensuring quick load times and uninterrupted access. Additionally, CleanAspire offers streamlined configuration options to help developers manage caching strategies and data synchronization effortlessly, guaranteeing that users receive the latest updates once the network is restored. + +**Key Features of Offline Support:** + +- **Service Workers Integration:** Efficiently handle caching and background synchronization to manage offline functionality. +- **Automatic Resource Caching:** Automatically caches essential assets and API responses, ensuring critical parts of the application are accessible offline. +- **Seamless Data Synchronization:** Maintains data consistency by synchronizing local changes with the server once the connection is reestablished. +- **User Experience Enhancements:** Provides fallback UI components and notifications to inform users about their offline status and any pending actions. + +By incorporating robust offline capabilities, CleanAspire empowers developers to build resilient applications that deliver a consistent and reliable user experience, regardless of network conditions. + ### 🔑 Key Features @@ -28,7 +41,7 @@ With a focus on **Clean Architecture** and **extreme code simplicity**, CleanAsp 4. **Blazor WebAssembly and PWA Integration** - Combines the power of Blazor WebAssembly for interactive and lightweight client-side UIs. - - PWA capabilities ensure offline support and a seamless native-like experience. + - PWA capabilities ensure offline support and a seamless native-like experience, allowing users to access the application and data even when offline. 5. **Streamlined API Client Integration** - Utilizes **Microsoft.Kiota** to automatically generate strongly-typed API clients, reducing development overhead. @@ -40,14 +53,21 @@ With a focus on **Clean Architecture** and **extreme code simplicity**, CleanAsp 7. **Cloud-Ready with Docker** - Preconfigured for Docker, enabling easy deployment to cloud platforms or local environments. -8. **Real-Time Web Push Notifications** - - Integrated **Webpushr** to deliver instant browser notifications. - - Keeps users informed and engaged with real-time updates. - - Fully customizable notifications with targeted delivery and analytics support. +8. **Real-Time Web Push Notifications** + - Integrated **Webpushr** to deliver instant browser notifications. + - Keeps users informed and engaged with real-time updates. + - Fully customizable notifications with targeted delivery and analytics support. 9. **Integrated CI/CD Pipelines** - Includes GitHub Actions workflows for automated building, testing, and deployment. +10. **Offline Mode Support** + - **Offline mode enabled by default** to provide a seamless experience even without internet access. + - Uses **IndexedDB** to cache data locally, allowing the application to retrieve data and function offline. + - The system detects the online/offline status and fetches data from **IndexedDB** when offline, ensuring uninterrupted access to key features. + + + ### 🌟 Why Choose CleanAspire? @@ -67,7 +87,7 @@ With a focus on **Clean Architecture** and **extreme code simplicity**, CleanAsp version: '3.8' services: apiservice: - image: blazordevlab/cleanaspire-api:0.0.47 + image: blazordevlab/cleanaspire-api:0.0.49 environment: - ASPNETCORE_ENVIRONMENT=Development - AllowedHosts=* @@ -88,7 +108,7 @@ services: webfrontend: - image: blazordevlab/cleanaspire-clientapp:0.0.47 + image: blazordevlab/cleanaspire-clientapp:0.0.49 ports: - "8016:80" - "8017:443" diff --git a/src/CleanAspire.Api/CleanAspire.Api.csproj b/src/CleanAspire.Api/CleanAspire.Api.csproj index 0dc1f19..83eb66c 100644 --- a/src/CleanAspire.Api/CleanAspire.Api.csproj +++ b/src/CleanAspire.Api/CleanAspire.Api.csproj @@ -16,7 +16,7 @@ - + diff --git a/src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs b/src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs index 8aaa184..cfab96b 100644 --- a/src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs +++ b/src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs @@ -25,6 +25,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes) }) .Produces>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Get all products") .WithDescription("Returns a list of all products in the system."); @@ -33,6 +34,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes) .Produces(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Get product by ID") .WithDescription("Returns the details of a specific product by its unique ID."); @@ -41,6 +43,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes) .Produces(StatusCodes.Status201Created) .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Create a new product") .WithDescription("Creates a new product with the provided details."); @@ -50,6 +53,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes) .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Update an existing product") .WithDescription("Updates the details of an existing product."); @@ -59,12 +63,15 @@ public void RegisterRoutes(IEndpointRouteBuilder routes) .ProducesValidationProblem(StatusCodes.Status422UnprocessableEntity) .ProducesProblem(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Delete products by IDs") .WithDescription("Deletes one or more products by their unique IDs."); // Get products with pagination and filtering group.MapPost("/pagination", ([FromServices] IMediator mediator, [FromBody] ProductsWithPaginationQuery query) => mediator.Send(query)) .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status500InternalServerError) .WithSummary("Get products with pagination") .WithDescription("Returns a paginated list of products based on search keywords, page size, and sorting options."); } diff --git a/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs b/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs index d550305..f2c2333 100644 --- a/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs +++ b/src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs @@ -41,21 +41,21 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e g => g.Select(e => e.ErrorMessage).ToArray() ) }, - UniqueConstraintException => new ProblemDetails + UniqueConstraintException e => new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Unique Constraint Violation", - Detail = "A unique constraint violation occurred.", + Detail = $"Unique constraint {e.ConstraintName} violated. Duplicate value for {e.ConstraintProperties[0]}", Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}", }, - CannotInsertNullException => new ProblemDetails + CannotInsertNullException e => new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Null Value Error", Detail = "A required field was null.", Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}", }, - MaxLengthExceededException => new ProblemDetails + MaxLengthExceededException e => new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Max Length Exceeded", @@ -92,8 +92,8 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e }, _ => new ProblemDetails { - Status = StatusCodes.Status400BadRequest, - Title = "Unhandled Exception", + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", Detail = "An unexpected error occurred. Please try again later.", Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}" } diff --git a/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs b/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs index fc554b2..a0703fd 100644 --- a/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs +++ b/src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs @@ -155,7 +155,7 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints(thi { return CreateValidationProblem(result); } - logger.LogInformation("User signup request received: {@SignupRequest}", request); + logger.LogInformation("User signup successful."); await SendConfirmationEmailAsync(user, userManager, context, request.Email); return TypedResults.Created(); }) diff --git a/src/CleanAspire.Application/CleanAspire.Application.csproj b/src/CleanAspire.Application/CleanAspire.Application.csproj index 66f8513..2b49a13 100644 --- a/src/CleanAspire.Application/CleanAspire.Application.csproj +++ b/src/CleanAspire.Application/CleanAspire.Application.csproj @@ -14,7 +14,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/CleanAspire.Application/Features/Products/Queries/GetProductByIdQuery.cs b/src/CleanAspire.Application/Features/Products/Queries/GetProductByIdQuery.cs index ebcdfbc..9b8163e 100644 --- a/src/CleanAspire.Application/Features/Products/Queries/GetProductByIdQuery.cs +++ b/src/CleanAspire.Application/Features/Products/Queries/GetProductByIdQuery.cs @@ -41,7 +41,6 @@ public GetProductByIdQueryHandler(IApplicationDbContext dbContext) { throw new KeyNotFoundException($"Product with Id '{request.Id}' was not found."); } - return product; } } diff --git a/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj b/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj index b63d67c..0f6007d 100644 --- a/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj +++ b/src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj @@ -19,15 +19,15 @@ - - + + - - - - + + + + diff --git a/src/CleanAspire.ClientApp/Client/.kiota/workspace.json b/src/CleanAspire.ClientApp/Client/.kiota/workspace.json new file mode 100644 index 0000000..3ce81de --- /dev/null +++ b/src/CleanAspire.ClientApp/Client/.kiota/workspace.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "clients": {}, + "plugins": {} +} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Components/Breadcrumbs/Breadcrumbs.razor b/src/CleanAspire.ClientApp/Components/Breadcrumbs/Breadcrumbs.razor index 3256a00..b99adad 100644 --- a/src/CleanAspire.ClientApp/Components/Breadcrumbs/Breadcrumbs.razor +++ b/src/CleanAspire.ClientApp/Components/Breadcrumbs/Breadcrumbs.razor @@ -46,11 +46,6 @@ [Parameter] public EventCallback OnPrintClick { get; set; } - private async Task GoBack() - { - await new HistoryGo(JS).GoBack(); - } - private async Task Save() { if (OnSaveButtonClick.HasDelegate) diff --git a/src/CleanAspire.ClientApp/Components/OfflineSyncStatus.razor b/src/CleanAspire.ClientApp/Components/OfflineSyncStatus.razor new file mode 100644 index 0000000..a019429 --- /dev/null +++ b/src/CleanAspire.ClientApp/Components/OfflineSyncStatus.razor @@ -0,0 +1,56 @@ +@inject OfflineSyncService OfflineSyncService + +
+ @if (OfflineSyncService.CurrentStatus == SyncStatus.Idle) + { + + } + else if (OfflineSyncService.CurrentStatus == SyncStatus.Completed) + { + @OfflineSyncService.StatusMessage + + + + + + + + + + + }else + { + @OfflineSyncService.StatusMessage + + + + + + + + + + + + + + + + + } +
+ +@code { + protected override void OnInitialized() + { + OfflineSyncService.OnSyncStateChanged += StateHasChanged; + } + + public void Dispose() + { + OfflineSyncService.OnSyncStateChanged -= StateHasChanged; + } + + +} diff --git a/src/CleanAspire.ClientApp/Components/WebpushrSetup.razor b/src/CleanAspire.ClientApp/Components/WebpushrSetup.razor index d3435e1..4e82f09 100644 --- a/src/CleanAspire.ClientApp/Components/WebpushrSetup.razor +++ b/src/CleanAspire.ClientApp/Components/WebpushrSetup.razor @@ -3,21 +3,13 @@ { if (firstRender) { - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Webpushr.Config.GetAsync()); - result.Switch( - async ok => - { - var webpushr = new Webpushr(JS); - await webpushr.SetupWebpushrAsync(ok.PublicKey!); - }, - invalid => - { - Snackbar.Add(L["Invalid configuration received. Please check the Webpushr settings."], Severity.Error); - }, - error => - { - Snackbar.Add(L["An error occurred while fetching the Webpushr configuration. Please try again later."], Severity.Error); - }); + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (online) + { + var publicKey = await WebpushrService.GetPublicKeyAsync(); + var webpushr = new Webpushr(JS); + await webpushr.SetupWebpushrAsync(publicKey!); + } } } } diff --git a/src/CleanAspire.ClientApp/DependencyInjection.cs b/src/CleanAspire.ClientApp/DependencyInjection.cs index 30061a2..2b60731 100644 --- a/src/CleanAspire.ClientApp/DependencyInjection.cs +++ b/src/CleanAspire.ClientApp/DependencyInjection.cs @@ -5,11 +5,11 @@ using CleanAspire.ClientApp.Services.Interfaces; using CleanAspire.ClientApp.Services.UserPreferences; using CleanAspire.ClientApp.Services; + namespace CleanAspire.ClientApp; public static class DependencyInjection { - public static void TryAddMudBlazor(this IServiceCollection services, IConfiguration config) { #region register MudBlazor.Services @@ -43,6 +43,6 @@ public static void TryAddMudBlazor(this IServiceCollection services, IConfigurat services.AddScoped(); #endregion } - + } diff --git a/src/CleanAspire.ClientApp/Layout/MainLayout.razor b/src/CleanAspire.ClientApp/Layout/MainLayout.razor index 946595d..0f13f01 100644 --- a/src/CleanAspire.ClientApp/Layout/MainLayout.razor +++ b/src/CleanAspire.ClientApp/Layout/MainLayout.razor @@ -15,13 +15,14 @@ public LayoutService LayoutService { get; set; } = default!; private MudThemeProvider _mudThemeProvider=default!; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { if (LayoutService != null) { LayoutService.MajorUpdateOccurred += LayoutServiceOnMajorUpdateOccured; } - base.OnInitialized(); + OnlineStatusInterop.Initialize(); + await OfflineModeState.InitializeAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) diff --git a/src/CleanAspire.ClientApp/Layout/Navbar.razor b/src/CleanAspire.ClientApp/Layout/Navbar.razor index d35726b..f9fe39b 100644 --- a/src/CleanAspire.ClientApp/Layout/Navbar.razor +++ b/src/CleanAspire.ClientApp/Layout/Navbar.razor @@ -73,7 +73,7 @@ } - + diff --git a/src/CleanAspire.ClientApp/Layout/UserMenu.razor b/src/CleanAspire.ClientApp/Layout/UserMenu.razor index fb5568b..10e7151 100644 --- a/src/CleanAspire.ClientApp/Layout/UserMenu.razor +++ b/src/CleanAspire.ClientApp/Layout/UserMenu.razor @@ -1,7 +1,6 @@ @using CleanAspire.ClientApp.Services.Identity @using Microsoft.AspNetCore.Components.Authorization -@inject IIdentityManagement IdentityManagement -@inject OnlineStatusService OnlineStatusService +@inject ISignInManagement IdentityManagement @inject LayoutService LayoutService @@ -75,11 +74,11 @@ } protected override async Task OnInitializedAsync() { - await OnlineStatusService.InitializeAsync(); - bool isOnline = await OnlineStatusService.GetOnlineStatusAsync(); + OnlineStatusInterop.Initialize(); + bool isOnline = await OnlineStatusInterop.GetOnlineStatusAsync(); UpdateStatus(isOnline); - OnlineStatusService.OnlineStatusChanged += UpdateStatus; - await OnlineStatusService.RegisterOnlineStatusListenerAsync(); + OnlineStatusInterop.OnlineStatusChanged += UpdateStatus; + } private void UpdateStatus(bool isOnline) { @@ -92,7 +91,7 @@ public async ValueTask DisposeAsync() { // Unsubscribe from events and dispose of resources - OnlineStatusService.OnlineStatusChanged -= UpdateStatus; - await OnlineStatusService.DisposeAsync(); + OnlineStatusInterop.OnlineStatusChanged -= UpdateStatus; + await OnlineStatusInterop.DisposeAsync(); } } diff --git a/src/CleanAspire.ClientApp/Pages/Account/Profile/AccountSetting.razor b/src/CleanAspire.ClientApp/Pages/Account/Profile/AccountSetting.razor index e31d2de..46968c6 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/Profile/AccountSetting.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/Profile/AccountSetting.razor @@ -27,7 +27,7 @@ + Validation="@(new EmailAddressAttribute() {ErrorMessage = L["The email address is invalid"]})"> @@ -56,7 +56,7 @@ + Validation="@(new Func(userNameMatch))"> @@ -76,12 +76,22 @@ private async Task OnChangeMyEmail() { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } if (success) { - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Account.UpdateEmail.PostAsync(new UpdateEmailRequest() + var result = await ApiClientService.ExecuteAsync(async () => + { + await ApiClient.Account.UpdateEmail.PostAsync(new UpdateEmailRequest() { NewEmail = newEmailAddress - })); + }); + return true; + }); result.Switch( ok => { @@ -100,12 +110,22 @@ } private async Task OnDeleteAccount() { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } if (deleteSuccess) { - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Account.DeleteOwnerAccount.DeleteAsync(new DeleteUserRequest() + var result = await ApiClientService.ExecuteAsync( async () => + { + await ApiClient.Account.DeleteOwnerAccount.DeleteAsync(new DeleteUserRequest() { Username = confirmUsername - })); + }); + return true; + }); result.Switch( ok => { diff --git a/src/CleanAspire.ClientApp/Pages/Account/Profile/OfflineModeSetting.razor b/src/CleanAspire.ClientApp/Pages/Account/Profile/OfflineModeSetting.razor new file mode 100644 index 0000000..b382db1 --- /dev/null +++ b/src/CleanAspire.ClientApp/Pages/Account/Profile/OfflineModeSetting.razor @@ -0,0 +1,51 @@ +@inject OfflineModeState OfflineModeState +
+ @L["Offline Mode"] + + + @L["Enabling offline mode stores certain data locally to ensure secure authentication and continued access when an internet connection is unavailable. However, local data storage may increase the risk of data breaches if your device is compromised. Please ensure your device is secure to mitigate this risk."] + + + @L["Offline mode allows access to essential features without requiring an internet connection, which is particularly useful in areas with limited or no connectivity."] + + + + + @L["Enable Offline Mode"] + + + + + @L["Activating offline mode grants access to critical features and data without the need for an active internet connection. This feature is ideal for environments where connectivity is unreliable or unavailable. Ensure your device is secure to prevent potential data risks."] + +
+ + @L["Enable Offline Mode"] + + +
+
+
+
+@code{ + protected override Task OnInitializedAsync() + { + OfflineModeState.OnChange += UpdateStatus; + return base.OnInitializedAsync(); + } + private async Task ValueChanged(bool value) + { + await OfflineModeState.SetOfflineModeAsync(value); + } + private async Task UpdateStatus() + { + await InvokeAsync(StateHasChanged); + + } + public ValueTask DisposeAsync() + { + // Unsubscribe from events and dispose of resources + OfflineModeState.OnChange -= UpdateStatus; + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/Pages/Account/Profile/ProfileSetting.razor b/src/CleanAspire.ClientApp/Pages/Account/Profile/ProfileSetting.razor index d44bbc4..db6e0ec 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/Profile/ProfileSetting.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/Profile/ProfileSetting.razor @@ -63,6 +63,12 @@ private async Task UploadFiles(IBrowserFile file) { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } try { var request = new MultipartBody(); @@ -89,6 +95,12 @@ } private async Task OnValidSubmit(EditContext context) { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } var result = await ApiClientService.ExecuteAsync(() => ApiClient.Account.Profile.PostAsync(new ProfileRequest() { AvatarUrl = model.AvatarUrl, diff --git a/src/CleanAspire.ClientApp/Pages/Account/Profile/Setting.razor b/src/CleanAspire.ClientApp/Pages/Account/Profile/Setting.razor index 9728c39..590614d 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/Profile/Setting.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/Profile/Setting.razor @@ -9,7 +9,9 @@ - + + + diff --git a/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor b/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor index 3ee6117..b44128c 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/SignIn.razor @@ -1,5 +1,5 @@ @page "/account/signin" -@inject IIdentityManagement IdentityManagement +@inject ISignInManagement SignInManagement @using System.ComponentModel.DataAnnotations @using CleanAspire.ClientApp.Services.Identity @L["Sign In"] @@ -18,15 +18,19 @@
+ Label="@L["User name"]" Placeholder="@L["User name"]" + Required="true" RequiredError="@L["user name is required"]">
- @L["remember me"] - @L["forget password?"] + + + @L["Remember me"] + + @L["Forget password?"]
- @L["Sign In"] + + @L["Sign In"]
@@ -35,6 +39,7 @@ @code { + private SignInModel model = new SignInModel() { Username = "Administrator", @@ -44,14 +49,14 @@ bool isShow; InputType PasswordInput = InputType.Password; string PasswordInputIcon = Icons.Material.Filled.VisibilityOff; + + private async Task OnValidSubmit(EditContext context) { try { - - var result = await IdentityManagement.LoginAsync(new Api.Client.Models.LoginRequest() { Email = model.Username, Password = model.Password }, model.RememberMe); + await SignInManagement.LoginAsync(new Api.Client.Models.LoginRequest() { Email = model.Username, Password = model.Password }, model.RememberMe); StateHasChanged(); - } catch (Exception e) { @@ -79,13 +84,15 @@ public class SignInModel { - [Required] + [Required(ErrorMessage = "Username is required.")] public string Username { get; set; } = string.Empty; - [Required] - [StringLength(30, ErrorMessage = "Password must be at least 6 characters long.", MinimumLength = 6)] - [RegularExpression(@"^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{6,}$", ErrorMessage = "Password must be at least 6 characters long and contain at least one letter, one number, and one special character.")] + + [Required(ErrorMessage = "Password is required.")] + [StringLength(30, ErrorMessage = "Password must be between 6 and 30 characters long.", MinimumLength = 6)] + [RegularExpression(@"^(?=.*[A-Za-z])(?=.*\d)(?=.*[\W_]).{6,}$", ErrorMessage = "Password must contain at least one letter, one number, and one special character.")] public string Password { get; set; } = string.Empty; - public bool RememberMe { get; set; } = true; + [Display(Name = "Remember Me")] + public bool RememberMe { get; set; } = true; } } diff --git a/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor b/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor index 0ee1920..9e7c705 100644 --- a/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor +++ b/src/CleanAspire.ClientApp/Pages/Account/SignUp.razor @@ -17,11 +17,11 @@
+ Placeholder="@L["Organization"]" + Label="@L["Select Organization"]" + Required="true" + RequiredError="@L["Organization selection is required"]" + @bind-Value="@model.Tenant"> @@ -45,8 +45,17 @@ private TenantDto tenantDto = new(); private async Task OnValidSubmit(EditContext context) { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } waiting = true; - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Account.Signup.PostAsync(new SignupRequest() { Email = model.Email, Password = model.Password, LanguageCode = model.LanguageCode, Nickname = model.Nickname, Provider = model.Provider, TimeZoneId = model.TimeZoneId, TenantId = model.Tenant?.Id })); + var result = await ApiClientService.ExecuteAsync(async () =>{ + await ApiClient.Account.Signup.PostAsync(new SignupRequest() { Email = model.Email, Password = model.Password, LanguageCode = model.LanguageCode, Nickname = model.Nickname, Provider = model.Provider, TimeZoneId = model.TimeZoneId, TenantId = model.Tenant?.Id }); + return true; + }); result.Switch( ok => { diff --git a/src/CleanAspire.ClientApp/Pages/Home.razor b/src/CleanAspire.ClientApp/Pages/Home.razor index a313beb..6864053 100644 --- a/src/CleanAspire.ClientApp/Pages/Home.razor +++ b/src/CleanAspire.ClientApp/Pages/Home.razor @@ -183,6 +183,9 @@ + + + diff --git a/src/CleanAspire.ClientApp/Pages/Products/Components/NewProductDialog.razor b/src/CleanAspire.ClientApp/Pages/Products/Components/NewProductDialog.razor index 8b88ac0..585ca50 100644 --- a/src/CleanAspire.ClientApp/Pages/Products/Components/NewProductDialog.razor +++ b/src/CleanAspire.ClientApp/Pages/Products/Components/NewProductDialog.razor @@ -1,5 +1,6 @@ @using CleanAspire.ClientApp.Components.Autocompletes - +@using CleanAspire.ClientApp.Services.Proxies +@inject ProductServiceProxy ProductServiceProxy @@ -24,8 +25,8 @@ + Label="@L["UOM"]" Placeholder="@L["Uom"]" + Required="false" RequiredError="@L["Uom is required"]"> @@ -36,8 +37,8 @@ + Label="@L["Currency"]" Placeholder="@L["Currency"]" + Required="true" RequiredError="@L["Currency is required"]"> @@ -68,26 +69,19 @@ if (success) { _saving = true; - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Products.PostAsync(model)); + + var result = await ProductServiceProxy.CreateProductAsync(model); result.Switch( async ok => { Snackbar.Add(L["Product created successfully!"], Severity.Success); - var baseUrl = Navigation.BaseUri.TrimEnd('/'); - var productUrl = $"{baseUrl}/products/edit/{ok.Id}"; - await WebpushrService.SendNotificationAsync("New Product Launched!", $"Our new product, {ok.Name}, is now available. Click to learn more!", $"{productUrl}"); MudDialog.Close(DialogResult.Ok(true)); - _saving= false; - }, - invalid => - { - Snackbar.Add(L[invalid.Message ?? "Failed to create product."], Severity.Error); - _saving= false; + _saving = false; }, error => { Snackbar.Add(L["Failed to create product."], Severity.Error); - _saving= false; + _saving = false; } ); } diff --git a/src/CleanAspire.ClientApp/Pages/Products/Edit.razor b/src/CleanAspire.ClientApp/Pages/Products/Edit.razor index a351478..9bf3a03 100644 --- a/src/CleanAspire.ClientApp/Pages/Products/Edit.razor +++ b/src/CleanAspire.ClientApp/Pages/Products/Edit.razor @@ -1,11 +1,12 @@ @page "/products/edit/{Id}" @using CleanAspire.ClientApp.Components.Autocompletes @using CleanAspire.ClientApp.Components.Breadcrumbs -@inherits MudComponentBase +@using CleanAspire.ClientApp.Services.Proxies +@inject ProductServiceProxy ProductServiceProxy @Title - + @if (model is not null) { @@ -13,43 +14,43 @@ + Label="@L["SKU"]" Placeholder="@L["SKU"]" + Required="true" RequiredError="@L["Sku is required"]"> + Label="@L["Name"]" Placeholder="@L["Name"]" + Required="true" RequiredError="@L["Name is required"]"> + Label="@L["Category"]" Placeholder="@L["Category"]" + Required="true" RequiredError="@L["Category is required"]"> + Label="@L["UOM"]" Placeholder="@L["Uom"]" + Required="false" RequiredError="@L["Uom is required"]"> + Label="@L["Price"]" Placeholder="@L["Price"]" + Required="true" RequiredError="@L["Price is required"]"> + Label="@L["Currency"]" Placeholder="@L["Currency"]" + Required="true" RequiredError="@L["Currency is required"]"> + Label="@L["Description"]" Placeholder="@L["Description"]"> @@ -65,6 +66,7 @@ [Parameter] public string? Id { get; set; } private bool _saving; + private bool _isOnline = true; bool success; string[] errors = { }; private UpdateProductCommand? model; @@ -76,37 +78,36 @@ }; protected override async Task OnInitializedAsync() { - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Products[Id].GetAsync()); + var result = await ProductServiceProxy.GetProductByIdAsync(Id); result.Switch( - ok => + dto => { model = new() { - Id = ok.Id, - Sku = ok.Sku, - Name = ok.Name, - Category = ok.Category, - Uom = ok.Uom, - Price = ok.Price, - Currency = ok.Currency, - Description = ok.Description + Id = dto.Id, + Sku = dto.Sku, + Name = dto.Name, + Category = dto.Category, + Uom = dto.Uom, + Price = dto.Price, + Currency = dto.Currency, + Description = dto.Description }; _breadcrumbItems.Add(new BreadcrumbItem(model.Sku, href: $"/products/edit/{Id}")); }, - invalid => - { - Snackbar.Add(invalid.Message, Severity.Error); - }, - error => - { - Snackbar.Add(error.Message, Severity.Error); - } + error => Snackbar.Add(error.Message, Severity.Error) ); } private async Task Save() { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } editForm?.Validate(); if (success == true) { diff --git a/src/CleanAspire.ClientApp/Pages/Products/Index.razor b/src/CleanAspire.ClientApp/Pages/Products/Index.razor index 63bffc0..8c9a9a8 100644 --- a/src/CleanAspire.ClientApp/Pages/Products/Index.razor +++ b/src/CleanAspire.ClientApp/Pages/Products/Index.razor @@ -1,18 +1,20 @@ @page "/products/index" @using CleanAspire.ClientApp.Pages.Products.Components +@using CleanAspire.ClientApp.Services.Proxies +@inject ProductServiceProxy ProductServiceProxy @Title + @ref="_table" + ServerData="@(ServerReload)" + MultiSelection="true" + SelectOnRowClick="false" + RowClick="@(r=>Edit(r.Item))" + RowStyleFunc="_rowStyleFunc" + @bind-RowsPerPage="_defaultPageSize" + @bind-SelectedItems="_selectedItems" + @bind-SelectedItem="_currentDto"> @@ -39,7 +41,7 @@ RowStyleFunc="_rowStyleFunc" + AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Small"> @@ -82,7 +84,7 @@ RowStyleFunc="_rowStyleFunc" query.Keywords = _keywords; query.OrderBy = state.SortDefinitions.FirstOrDefault()?.SortBy ?? "Id"; query.SortDirection = state.SortDefinitions.FirstOrDefault()?.Descending ?? true ? SortDirection.Descending.ToString() : SortDirection.Ascending.ToString(); - var result = await ApiClient.Products.Pagination.PostAsync(query); + var result = await ProductServiceProxy.GetPaginatedProductsAsync(query); return new GridData { TotalItems = (int)result.TotalItems, Items = result.Items }; } finally @@ -118,12 +120,18 @@ RowStyleFunc="_rowStyleFunc" } private async Task Delete() { + var online = await OnlineStatusInterop.GetOnlineStatusAsync(); + if (!online) + { + Snackbar.Add(L["You are offline. Please check your internet connection."], Severity.Error); + return; + } await DialogServiceHelper.ShowConfirmationDialog("delete confirm", L["Are you sure you want to delete the selected items?"], async () => { if (_selectedItems.Any()) { var ids = _selectedItems.Select(x => x.Id).ToList(); - var result = await ApiClientService.ExecuteAsync(() => ApiClient.Products.DeleteAsync(new DeleteProductCommand() { Ids = ids })); + var result = await ProductServiceProxy.DeleteProductsAsync(ids); result.Switch( async ok => { @@ -132,10 +140,6 @@ RowStyleFunc="_rowStyleFunc" StateHasChanged(); Snackbar.Add(L["Selected items have been deleted."], Severity.Success); }, - invalid => - { - Snackbar.Add(L[invalid.Message ?? "Failed to delete selected items."], Severity.Error); - }, error => { Snackbar.Add(L["Failed to delete selected items."], Severity.Error); diff --git a/src/CleanAspire.ClientApp/Program.cs b/src/CleanAspire.ClientApp/Program.cs index 75f5f49..1b442f3 100644 --- a/src/CleanAspire.ClientApp/Program.cs +++ b/src/CleanAspire.ClientApp/Program.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using CleanAspire.ClientApp; using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.Extensions.Options; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary; using CleanAspire.ClientApp.Services.Identity; @@ -14,8 +13,8 @@ using Microsoft.Kiota.Serialization.Form; using Microsoft.Kiota.Serialization.Multipart; using CleanAspire.ClientApp.Services; -using Microsoft.Extensions.DependencyInjection; using CleanAspire.ClientApp.Services.JsInterop; +using CleanAspire.ClientApp.Services.Proxies; var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -26,7 +25,11 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var clientAppSettings = builder.Configuration.GetSection(ClientAppSettings.KEY).Get(); builder.Services.AddSingleton(clientAppSettings!); @@ -82,7 +85,7 @@ // register the account management interface builder.Services.AddScoped( - sp => (IIdentityManagement)sp.GetRequiredService()); + sp => (ISignInManagement)sp.GetRequiredService()); builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); @@ -90,6 +93,5 @@ var app = builder.Build(); - await app.RunAsync(); diff --git a/src/CleanAspire.ClientApp/Services/ApiClientService.cs b/src/CleanAspire.ClientApp/Services/ApiClientService.cs index 1e47734..e2c9d1e 100644 --- a/src/CleanAspire.ClientApp/Services/ApiClientService.cs +++ b/src/CleanAspire.ClientApp/Services/ApiClientService.cs @@ -45,36 +45,6 @@ public async Task> Ex return new ApiClientError(ex.Message, ex); } } - - public async Task> ExecuteAsync(Func apiCall) - { - try - { - await apiCall(); - return true; - } - catch (HttpValidationProblemDetails ex) - { - - _logger.LogError(ex, ex.Message); - return new ApiClientValidationError(ex.Detail, ex); - } - catch (ProblemDetails ex) - { - _logger.LogError(ex, ex.Message); - return new ApiClientError(ex.Detail, ex); - } - catch (ApiException ex) - { - _logger.LogError(ex, ex.Message); - return new ApiClientError(ex.Message, ex); - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - return new ApiClientError(ex.Message, ex); - } - } } public class ApiClientError diff --git a/src/CleanAspire.ClientApp/Services/DialogServiceHelper.cs b/src/CleanAspire.ClientApp/Services/DialogServiceHelper.cs index 35a82d8..74ce78c 100644 --- a/src/CleanAspire.ClientApp/Services/DialogServiceHelper.cs +++ b/src/CleanAspire.ClientApp/Services/DialogServiceHelper.cs @@ -1,6 +1,5 @@ using CleanAspire.ClientApp.Components; using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Options; using MudBlazor; namespace CleanAspire.ClientApp.Services; diff --git a/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs b/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs index 7e1c4ee..475d8eb 100644 --- a/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs +++ b/src/CleanAspire.ClientApp/Services/Identity/CookieAuthenticationStateProvider.cs @@ -2,31 +2,52 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Net.Http; using System.Security.Claims; -using System.Text.Json; using CleanAspire.Api.Client; using CleanAspire.Api.Client.Models; +using CleanAspire.ClientApp.Services.JsInterop; using Microsoft.AspNetCore.Components.Authorization; + + + using Microsoft.Kiota.Abstractions; + namespace CleanAspire.ClientApp.Services.Identity; -public class CookieAuthenticationStateProvider(ApiClient apiClient, UserProfileStore profileStore) : AuthenticationStateProvider, IIdentityManagement +public class CookieAuthenticationStateProvider(ApiClient apiClient, UserProfileStore profileStore, IServiceProvider serviceProvider) : AuthenticationStateProvider, ISignInManagement { + private const string CACHEKEY_CREDENTIAL = "_Credential"; private bool authenticated = false; private readonly ClaimsPrincipal unauthenticated = new(new ClaimsIdentity()); public override async Task GetAuthenticationStateAsync() { + var indexedDb = serviceProvider.GetRequiredService(); + var onlineStatusInterop = serviceProvider.GetRequiredService(); + var offlineState = serviceProvider.GetRequiredService(); + bool enableOffline = offlineState.Enabled; authenticated = false; - // default to not authenticated var user = unauthenticated; - + ProfileResponse? profileResponse = null; try { - // the user info endpoint is secured, so if the user isn't logged in this will fail - var profileResponse = await apiClient.Account.Profile.GetAsync(); + var isOnline = await onlineStatusInterop.GetOnlineStatusAsync(); + if (isOnline) + { + // the user info endpoint is secured, so if the user isn't logged in this will fail + profileResponse = await apiClient.Account.Profile.GetAsync(); + // store the profile to indexedDB + if (profileResponse != null && enableOffline) + { + await indexedDb.SaveDataAsync(IndexedDbCache.DATABASENAME, CACHEKEY_CREDENTIAL, profileResponse); + } + } + else if (enableOffline) + { + profileResponse = await indexedDb.GetDataAsync(IndexedDbCache.DATABASENAME, CACHEKEY_CREDENTIAL); + } + profileStore.Set(profileResponse); if (profileResponse != null) { @@ -48,31 +69,58 @@ public override async Task GetAuthenticationStateAsync() } } catch { } - // return the state return new AuthenticationState(user); } - public async Task LoginAsync(LoginRequest request, bool remember = false, CancellationToken cancellationToken = default) + public async Task LoginAsync(LoginRequest request, bool remember = true, CancellationToken cancellationToken = default) { + var indexedDb = serviceProvider.GetRequiredService(); + var onlineStatusInterop = serviceProvider.GetRequiredService(); + var offlineState = serviceProvider.GetRequiredService(); + bool offlineModel = offlineState.Enabled; try { - // login with cookies - var response = await apiClient.Login.PostAsync(request, options => - { - options.QueryParameters.UseCookies = remember; - options.QueryParameters.UseSessionCookies = !remember; - }, cancellationToken); - // need to refresh auth state + var isOnline = await onlineStatusInterop.GetOnlineStatusAsync(); + if (isOnline) + { + // Online login + var response = await apiClient.Login.PostAsync(request, options => + { + options.QueryParameters.UseCookies = remember; + options.QueryParameters.UseSessionCookies = !remember; + }, cancellationToken); + if (offlineModel) + { + // Store response in IndexedDB for offline access + await indexedDb.SaveDataAsync(IndexedDbCache.DATABASENAME,request.Email!, request.Email); + } + } + else if (offlineModel) + { + // Offline login logic + var storedToken = await indexedDb.GetDataAsync(IndexedDbCache.DATABASENAME, request.Email!); + if (storedToken == null) + { + throw new InvalidOperationException("No offline data available for the provided email."); + } + } + // Refresh authentication state NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); - return response ?? new AccessTokenResponse(); } catch (ApiException ex) { - throw; // Re-throwing the exception without changing the stack information + // Log and re-throw API exception + throw; + } + catch (Exception ex) + { + // Log and re-throw general exception + throw; } } + public async Task LogoutAsync(CancellationToken cancellationToken = default) { await apiClient.Account.Logout.PostAsync(cancellationToken: cancellationToken); @@ -80,9 +128,5 @@ public async Task LogoutAsync(CancellationToken cancellationToken = default) NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } - public Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default) - { - throw new NotImplementedException(); - } } diff --git a/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs b/src/CleanAspire.ClientApp/Services/Identity/ISignInManagement.cs similarity index 56% rename from src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs rename to src/CleanAspire.ClientApp/Services/Identity/ISignInManagement.cs index 1bf2778..6692987 100644 --- a/src/CleanAspire.ClientApp/Services/Identity/IIdentityManagement.cs +++ b/src/CleanAspire.ClientApp/Services/Identity/ISignInManagement.cs @@ -6,10 +6,9 @@ namespace CleanAspire.ClientApp.Services.Identity; -public interface IIdentityManagement +public interface ISignInManagement { - public Task LoginAsync(LoginRequest request,bool remember=false, CancellationToken cancellationToken = default); + public Task LoginAsync(LoginRequest request,bool remember=true, CancellationToken cancellationToken = default); public Task LogoutAsync(CancellationToken cancellationToken = default); - public Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default); } diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/DisplayModeInterop.cs b/src/CleanAspire.ClientApp/Services/JsInterop/DisplayModeInterop.cs new file mode 100644 index 0000000..e2fbc05 --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/JsInterop/DisplayModeInterop.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.JSInterop; + +namespace CleanAspire.ClientApp.Services.JsInterop; + +public sealed class DisplayModeInterop +{ + private readonly IJSRuntime _jsRuntime; + + public DisplayModeInterop(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + public async Task GetDisplayModeAsync() + { + return await _jsRuntime.InvokeAsync("displayModeInterop.getDisplayMode"); + } +} diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/HistoryGo.cs b/src/CleanAspire.ClientApp/Services/JsInterop/HistoryGo.cs deleted file mode 100644 index 4059ca9..0000000 --- a/src/CleanAspire.ClientApp/Services/JsInterop/HistoryGo.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.JSInterop; - -namespace CleanAspire.ClientApp.Services.JsInterop; - -public class HistoryGo -{ - private readonly IJSRuntime _jsRuntime; - public HistoryGo(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - public async Task GoBack(int value = -1) - { - var jsmodule = await _jsRuntime.InvokeAsync("import", "/js/historygo.js").ConfigureAwait(false); - return jsmodule.InvokeVoidAsync("historyGo", value); - } -} diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/IndexedDbCache.cs b/src/CleanAspire.ClientApp/Services/JsInterop/IndexedDbCache.cs new file mode 100644 index 0000000..c9d2983 --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/JsInterop/IndexedDbCache.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using Microsoft.JSInterop; + +namespace CleanAspire.ClientApp.Services.JsInterop; + +public sealed class IndexedDbCache +{ + public const string DATABASENAME = "CleanAspire.IndexedDB"; + private readonly IJSRuntime _jsRuntime; + + public IndexedDbCache(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + // Save data to IndexedDB with optional tags + public async Task SaveDataAsync(string dbName, string key, T value, string[] tags = null) + { + await _jsRuntime.InvokeVoidAsync("indexedDbStorage.saveData", dbName, key, value, tags ?? Array.Empty()); + } + + // Get data from IndexedDB by key + public async Task GetDataAsync(string dbName, string key) + { + return await _jsRuntime.InvokeAsync("indexedDbStorage.getData", dbName, key); + } + + // Get all data by tags (supports array of tags) + public async Task> GetDataByTagsAsync(string dbName, string[] tags) + { + // Call the JavaScript function and retrieve a list of { key, value } + var results = await _jsRuntime.InvokeAsync>>( + "indexedDbStorage.getDataByTags", dbName, tags); + + // Convert the results to a dictionary + return results.ToDictionary( + result => result["key"].ToString(), // Extract the key as a string + result => + { + // Handle deserialization of 'value' + var jsonElement = result["value"]; + return JsonSerializer.Deserialize(jsonElement.ToString(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + ); + } + // Delete specific data by key + public async Task DeleteDataAsync(string dbName, string key) + { + await _jsRuntime.InvokeVoidAsync("indexedDbStorage.deleteData", dbName, key); + } + + // Delete all data by tags (supports array of tags) + public async Task DeleteDataByTagsAsync(string dbName, string[] tags) + { + await _jsRuntime.InvokeVoidAsync("indexedDbStorage.deleteDataByTags", dbName, tags); + } + + // Clear all data from IndexedDB store + public async Task ClearDataAsync(string dbName) + { + await _jsRuntime.InvokeVoidAsync("indexedDbStorage.clearData", dbName); + } +} diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusInterop.cs b/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusInterop.cs new file mode 100644 index 0000000..7be14fa --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusInterop.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + + +namespace CleanAspire.ClientApp.Services.JsInterop; + +using Microsoft.JSInterop; +using System; +using System.Threading.Tasks; + +public class OnlineStatusInterop(IJSRuntime jsRuntime) : IAsyncDisposable +{ + private DotNetObjectReference? _dotNetRef; + + public event Action? OnlineStatusChanged; + + public void Initialize() + { + _dotNetRef = DotNetObjectReference.Create(this); + jsRuntime.InvokeVoidAsync("onlineStatusInterop.addOnlineStatusListener", _dotNetRef); + } + + public async Task GetOnlineStatusAsync() + { + return await jsRuntime.InvokeAsync("onlineStatusInterop.getOnlineStatus"); + } + + [JSInvokable] + public void UpdateOnlineStatus(bool isOnline) + { + OnlineStatusChanged?.Invoke(isOnline); + } + + public ValueTask DisposeAsync() + { + _dotNetRef?.Dispose(); + return ValueTask.CompletedTask; + } +} + diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs b/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs deleted file mode 100644 index b8bd105..0000000 --- a/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using Microsoft.JSInterop; - -namespace CleanAspire.ClientApp.Services.JsInterop; - -using Microsoft.JSInterop; -using System; -using System.Threading.Tasks; - -public class OnlineStatusService : IAsyncDisposable -{ - private readonly IJSRuntime _jsRuntime; - private IJSObjectReference? _jsModule; - private DotNetObjectReference? _dotNetRef; - - public event Action? OnlineStatusChanged; - - public OnlineStatusService(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - public async Task InitializeAsync() - { - _jsModule = await _jsRuntime.InvokeAsync("import", "/js/onlinestatus.js"); - _dotNetRef = DotNetObjectReference.Create(this); - } - - public async Task GetOnlineStatusAsync() - { - if (_jsModule == null) - { - throw new InvalidOperationException("JavaScript module is not initialized. Call InitializeAsync first."); - } - return await _jsModule.InvokeAsync("getOnlineStatus"); - } - public async Task RegisterOnlineStatusListenerAsync() - { - if (_jsModule == null) - { - throw new InvalidOperationException("JavaScript module is not initialized. Call InitializeAsync first."); - } - await _jsModule.InvokeVoidAsync("addOnlineStatusListener", _dotNetRef); - } - - [JSInvokable] - public void UpdateOnlineStatus(bool isOnline) - { - OnlineStatusChanged?.Invoke(isOnline); - } - - public async ValueTask DisposeAsync() - { - if (_dotNetRef != null) - { - _dotNetRef.Dispose(); - } - if (_jsModule != null) - { - await _jsModule.DisposeAsync(); - } - } -} diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/Webpushr.cs b/src/CleanAspire.ClientApp/Services/JsInterop/Webpushr.cs index 51e25cb..817bac0 100644 --- a/src/CleanAspire.ClientApp/Services/JsInterop/Webpushr.cs +++ b/src/CleanAspire.ClientApp/Services/JsInterop/Webpushr.cs @@ -6,18 +6,10 @@ namespace CleanAspire.ClientApp.Services.JsInterop; -public class Webpushr +public class Webpushr(IJSRuntime jsRuntime) { - private readonly IJSRuntime _jsRuntime; - - public Webpushr(IJSRuntime jsRuntime) - { - _jsRuntime = jsRuntime; - } - public async Task SetupWebpushrAsync(string key) { - var module = await _jsRuntime.InvokeAsync("import", "./js/webpushr.js"); - await module.InvokeVoidAsync("setupWebpushr", key); + await jsRuntime.InvokeVoidAsync("webpushrInterop.setupWebpushr", key); } } diff --git a/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs b/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs index 98c8480..eb5903f 100644 --- a/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs +++ b/src/CleanAspire.ClientApp/Services/Navigation/NavbarMenu.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using MudBlazor; -using static MudBlazor.CategoryTypes; namespace CleanAspire.ClientApp.Services.Navigation; diff --git a/src/CleanAspire.ClientApp/Services/OfflineModeState.cs b/src/CleanAspire.ClientApp/Services/OfflineModeState.cs new file mode 100644 index 0000000..8842768 --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/OfflineModeState.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CleanAspire.ClientApp.Services.Interfaces; + +namespace CleanAspire.ClientApp.Services; + +public class OfflineModeState +{ + private const string OfflineModeKey = "_offlineMode"; + private readonly IStorageService _storageService; + public bool Enabled { get; private set; } + + public event Func? OnChange; + public OfflineModeState(IStorageService storageService) + { + _storageService = storageService; + // Initialize the OfflineModeEnabled with a default value + Enabled = true; + } + // Initialize the offline mode setting from localStorage + public async Task InitializeAsync() + { + var storedValue = await _storageService.GetItemAsync(OfflineModeKey); + Enabled = storedValue ?? true; + } + // Update the OfflineModeEnabled and persist it to localStorage + public async Task SetOfflineModeAsync(bool isEnabled) + { + Enabled = isEnabled; + await _storageService.SetItemAsync(OfflineModeKey, isEnabled); + NotifyStateChanged(); + } + + private void NotifyStateChanged() => OnChange?.Invoke(); +} diff --git a/src/CleanAspire.ClientApp/Services/OfflineSyncService.cs b/src/CleanAspire.ClientApp/Services/OfflineSyncService.cs new file mode 100644 index 0000000..d6f1408 --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/OfflineSyncService.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace CleanAspire.ClientApp.Services; + +public sealed class OfflineSyncService +{ + public SyncStatus CurrentStatus { get; private set; } = SyncStatus.Idle; + public string StatusMessage { get; private set; } = "Idle"; + + public int TotalRequests { get; private set; } + public int RequestsProcessed { get; private set; } + public event Action? OnSyncStateChanged; + public void SetSyncStatus(SyncStatus status, string message, int total = 0, int processed = 0) + { + CurrentStatus = status; + StatusMessage = message; + TotalRequests = total; + RequestsProcessed = processed; + OnSyncStateChanged?.Invoke(); + } +} +public enum SyncStatus +{ + Idle, + Syncing, + Completed, + Failed +} diff --git a/src/CleanAspire.ClientApp/Services/Proxies/ProductServiceProxy.cs b/src/CleanAspire.ClientApp/Services/Proxies/ProductServiceProxy.cs new file mode 100644 index 0000000..deb43fc --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/Proxies/ProductServiceProxy.cs @@ -0,0 +1,238 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CleanAspire.Api.Client; +using CleanAspire.Api.Client.Models; +using CleanAspire.ClientApp.Services.JsInterop; +using Microsoft.AspNetCore.Components; +using Microsoft.Kiota.Abstractions; +using MudBlazor.Charts; +using OneOf; + + +namespace CleanAspire.ClientApp.Services.Proxies; + +public class ProductServiceProxy +{ + private const string OFFLINECREATECOMMANDCACHEKEY = "OfflineCreateCommand:Product"; + private readonly NavigationManager _navigationManager; + private readonly WebpushrService _webpushrService; + private readonly ApiClient _apiClient; + private readonly IndexedDbCache _indexedDbCache; + private readonly OnlineStatusInterop _onlineStatusInterop; + private readonly OfflineModeState _offlineModeState; + private readonly OfflineSyncService _offlineSyncService; + private bool _previousOnlineStatus; + public ProductServiceProxy(NavigationManager navigationManager, WebpushrService webpushrService, ApiClient apiClient, IndexedDbCache indexedDbCache, OnlineStatusInterop onlineStatusInterop, OfflineModeState offlineModeState, OfflineSyncService offlineSyncService) + { + _navigationManager = navigationManager; + _webpushrService = webpushrService; + _apiClient = apiClient; + _indexedDbCache = indexedDbCache; + _onlineStatusInterop = onlineStatusInterop; + _offlineModeState = offlineModeState; + _offlineSyncService = offlineSyncService; + Initialize(); + } + private void Initialize() + { + _onlineStatusInterop.OnlineStatusChanged -= OnOnlineStatusChanged; + _onlineStatusInterop.OnlineStatusChanged += OnOnlineStatusChanged; + } + + private async void OnOnlineStatusChanged(bool isOnline) + { + if (_previousOnlineStatus == isOnline) + return; + _previousOnlineStatus = isOnline; + if (isOnline) + { + await SyncOfflineCachedDataAsync(); + } + } + public async Task GetPaginatedProductsAsync(ProductsWithPaginationQuery paginationQuery) + { + var cacheKey = GeneratePaginationCacheKey(paginationQuery); + var isOnline = await _onlineStatusInterop.GetOnlineStatusAsync(); + + if (isOnline) + { + var paginatedProducts = await _apiClient.Products.Pagination.PostAsync(paginationQuery); + + if (paginatedProducts != null && _offlineModeState.Enabled) + { + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, cacheKey, paginatedProducts, new[] { "products_pagination" }); + + foreach (var productDto in paginatedProducts.Items) + { + var productCacheKey = GenerateProductCacheKey(productDto.Id); + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, productCacheKey, productDto, new[] { "product" }); + } + } + + return paginatedProducts ?? new PaginatedResultOfProductDto(); + } + else + { + var cachedPaginatedProducts = await _indexedDbCache.GetDataAsync(IndexedDbCache.DATABASENAME, cacheKey); + if (cachedPaginatedProducts != null) + { + return cachedPaginatedProducts; + } + } + + return new PaginatedResultOfProductDto(); + } + + public async Task> GetProductByIdAsync(string productId) + { + var productCacheKey = GenerateProductCacheKey(productId); + var isOnline = await _onlineStatusInterop.GetOnlineStatusAsync(); + + if (isOnline) + { + try + { + var productDetails = await _apiClient.Products[productId].GetAsync(); + if (productDetails != null && _offlineModeState.Enabled) + { + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, productCacheKey, productDetails, new[] { "product" }); + } + return productDetails!; + } + catch + { + return new KeyNotFoundException($"Product with ID '{productId}' could not be fetched from the API."); + } + } + else + { + var cachedProductDetails = await _indexedDbCache.GetDataAsync(IndexedDbCache.DATABASENAME, productCacheKey); + if (cachedProductDetails != null) + { + return cachedProductDetails; + } + else + { + return new KeyNotFoundException($"Product with ID '{productId}' not found in offline cache."); + } + } + } + + public async Task> CreateProductAsync(CreateProductCommand command) + { + var isOnline = await _onlineStatusInterop.GetOnlineStatusAsync(); + if (isOnline) + { + try + { + var response = await _apiClient.Products.PostAsync(command); + var baseUrl = _navigationManager.BaseUri.TrimEnd('/'); + var productUrl = $"{baseUrl}/products/edit/{response.Id}"; + await _webpushrService.SendNotificationAsync("New Product Launched!", $"Our new product, {response.Name}, is now available. Click to learn more!", $"{productUrl}"); + + return response; + } + catch (ApiException ex) + { + return ex; + } + } + else + { + var cachedCommands = await _indexedDbCache.GetDataAsync>(IndexedDbCache.DATABASENAME, OFFLINECREATECOMMANDCACHEKEY) + ?? new List(); + cachedCommands.Add(command); + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, OFFLINECREATECOMMANDCACHEKEY, cachedCommands, new[] { "product_commands" }); + var productDto = new ProductDto() + { + Id = Guid.CreateVersion7().ToString(), + Category = command.Category, + Currency = command.Currency, + Description = command.Description, + Name = command.Name, + Price = command.Price, + Sku = command.Sku, + Uom = command.Uom + }; + var productCacheKey = GenerateProductCacheKey(productDto.Id); + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, productCacheKey, productDto, new[] { "product" }); + var cachedPaginatedProducts = await _indexedDbCache.GetDataByTagsAsync(IndexedDbCache.DATABASENAME, new[] { "products_pagination" }); + if (cachedPaginatedProducts != null && cachedPaginatedProducts.Any()) + { + foreach (var dic in cachedPaginatedProducts) + { + var key = dic.Key; + var paginatedProducts = dic.Value; + paginatedProducts.Items.Insert(0, productDto); + paginatedProducts.TotalItems++; + await _indexedDbCache.SaveDataAsync(IndexedDbCache.DATABASENAME, key, paginatedProducts, new[] { "products_pagination" }); + } + } + return productDto; + } + } + + public async Task> DeleteProductsAsync(List productIds) + { + var isOnline = await _onlineStatusInterop.GetOnlineStatusAsync(); + if (isOnline) + { + try + { + await _apiClient.Products.DeleteAsync(new DeleteProductCommand() { Ids = productIds }); + await _indexedDbCache.DeleteDataByTagsAsync(IndexedDbCache.DATABASENAME, new[] { "products_pagination","product" }); + return true; + } + catch (ApiException ex) + { + return ex; + } + } + return true; + } + public async Task SyncOfflineCachedDataAsync() + { + var cachedCreateProductCommands = await _indexedDbCache.GetDataAsync>( + IndexedDbCache.DATABASENAME, + OFFLINECREATECOMMANDCACHEKEY); + if (cachedCreateProductCommands != null && cachedCreateProductCommands.Any()) + { + var count = cachedCreateProductCommands.Count; + var processedCount = 0; + _offlineSyncService.SetSyncStatus(SyncStatus.Syncing, $"Starting sync: 0/{count} ...", count, processedCount); + await Task.Delay(500); + foreach (var command in cachedCreateProductCommands) + { + var result = await CreateProductAsync(command); + result.Switch( + productDto => + { + processedCount++; + _offlineSyncService.SetSyncStatus(SyncStatus.Syncing, $"Syncing {processedCount}/{count} Success.", count, processedCount); + }, + apiException => + { + processedCount++; + _offlineSyncService.SetSyncStatus(SyncStatus.Syncing, $"Syncing {processedCount}/{count} Failed ({apiException.Message}).", count, processedCount); + }); + await Task.Delay(500); + } + _offlineSyncService.SetSyncStatus(SyncStatus.Completed, $"Sync completed: {processedCount}/{count} processed.", count, processedCount); + await Task.Delay(1200); + await _indexedDbCache.DeleteDataAsync(IndexedDbCache.DATABASENAME, OFFLINECREATECOMMANDCACHEKEY); + _offlineSyncService.SetSyncStatus(SyncStatus.Idle, "", 0, 0); + } + } + + private string GeneratePaginationCacheKey(ProductsWithPaginationQuery query) + { + return $"{nameof(ProductsWithPaginationQuery)}:{query.PageNumber}_{query.PageSize}_{query.Keywords}_{query.OrderBy}_{query.SortDirection}"; + } + private string GenerateProductCacheKey(string productId) + { + return $"{nameof(ProductDto)}:{productId}"; + } +} + diff --git a/src/CleanAspire.ClientApp/Services/WebpushrAuthHandler.cs b/src/CleanAspire.ClientApp/Services/WebpushrAuthHandler.cs index 5886dd5..e65ff24 100644 --- a/src/CleanAspire.ClientApp/Services/WebpushrAuthHandler.cs +++ b/src/CleanAspire.ClientApp/Services/WebpushrAuthHandler.cs @@ -2,11 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Text.Json; using CleanAspire.Api.Client; using CleanAspire.Api.Client.Models; using CleanAspire.ClientApp.Services.Interfaces; -using Microsoft.JSInterop; namespace CleanAspire.ClientApp.Services; diff --git a/src/CleanAspire.ClientApp/Services/WebpushrService.cs b/src/CleanAspire.ClientApp/Services/WebpushrService.cs index c85f301..8f48b58 100644 --- a/src/CleanAspire.ClientApp/Services/WebpushrService.cs +++ b/src/CleanAspire.ClientApp/Services/WebpushrService.cs @@ -4,20 +4,46 @@ using System.Net.Http.Headers; using System.Text; +using CleanAspire.Api.Client; +using CleanAspire.ClientApp.Services.Interfaces; namespace CleanAspire.ClientApp.Services; public class WebpushrService { private readonly IHttpClientFactory _httpClientFactory; + private readonly ApiClient _apiClient; + private readonly IStorageService _storageService; private readonly ILogger _logger; - - public WebpushrService(IHttpClientFactory httpClientFactory, ILogger logger) + private const string WEBPUSHRPUBLICKEY = "_webpushrPublicKey"; + public WebpushrService(IHttpClientFactory httpClientFactory, ApiClient apiClient, IStorageService storageService, ILogger logger) { _httpClientFactory = httpClientFactory; + _apiClient = apiClient; + _storageService = storageService; _logger = logger; } - + public async Task GetPublicKeyAsync() + { + try + { + var publicKey = await _storageService.GetItemAsync(WEBPUSHRPUBLICKEY); + if (string.IsNullOrEmpty(publicKey)) + { + var res = await _apiClient.Webpushr.Config.GetAsync(); + if (res != null) + { + await _storageService.SetItemAsync(WEBPUSHRPUBLICKEY, res.PublicKey); + publicKey = res.PublicKey; + } + } + return publicKey??string.Empty; + } + catch (Exception ex) + { + return string.Empty; + } + } public async Task SendNotificationAsync(string title, string message, string targetUrl, string? sid = null) { var client = _httpClientFactory.CreateClient("Webpushr"); diff --git a/src/CleanAspire.ClientApp/_Imports.razor b/src/CleanAspire.ClientApp/_Imports.razor index 1e154f5..f2a1f1a 100644 --- a/src/CleanAspire.ClientApp/_Imports.razor +++ b/src/CleanAspire.ClientApp/_Imports.razor @@ -32,4 +32,6 @@ @inject ApiClient ApiClient @inject ApiClientService ApiClientService @inject UserProfileStore UserProfileStore -@inject WebpushrService WebpushrService \ No newline at end of file +@inject WebpushrService WebpushrService +@inject OnlineStatusInterop OnlineStatusInterop +@inject OfflineModeState OfflineModeState \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/appsettings.Development.json b/src/CleanAspire.ClientApp/wwwroot/appsettings.Development.json index 6796b0b..c32edd8 100644 --- a/src/CleanAspire.ClientApp/wwwroot/appsettings.Development.json +++ b/src/CleanAspire.ClientApp/wwwroot/appsettings.Development.json @@ -7,7 +7,7 @@ }, "ClientAppSettings": { "AppName": "Progressive Web Application", - "Version": "v0.0.47", + "Version": "v0.0.49", "ServiceBaseUrl": "https://localhost:7341" } } diff --git a/src/CleanAspire.ClientApp/wwwroot/appsettings.json b/src/CleanAspire.ClientApp/wwwroot/appsettings.json index 0ab58d4..1b52b80 100644 --- a/src/CleanAspire.ClientApp/wwwroot/appsettings.json +++ b/src/CleanAspire.ClientApp/wwwroot/appsettings.json @@ -7,7 +7,7 @@ }, "ClientAppSettings": { "AppName": "Progressive Web Application", - "Version": "v0.0.47", + "Version": "v0.0.49", "ServiceBaseUrl": "https://apiservice.blazorserver.com" } } diff --git a/src/CleanAspire.ClientApp/wwwroot/index.html b/src/CleanAspire.ClientApp/wwwroot/index.html index f918320..8758b71 100644 --- a/src/CleanAspire.ClientApp/wwwroot/index.html +++ b/src/CleanAspire.ClientApp/wwwroot/index.html @@ -230,9 +230,24 @@ - - - + + + + diff --git a/src/CleanAspire.ClientApp/wwwroot/js/historygo.js b/src/CleanAspire.ClientApp/wwwroot/js/historygo.js deleted file mode 100644 index 5446ff8..0000000 --- a/src/CleanAspire.ClientApp/wwwroot/js/historygo.js +++ /dev/null @@ -1,3 +0,0 @@ -export function historyGo(value) { - window.history.go(value??-1); -} \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/js/indexeddbstorage.js b/src/CleanAspire.ClientApp/wwwroot/js/indexeddbstorage.js new file mode 100644 index 0000000..bd3e9a3 --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/js/indexeddbstorage.js @@ -0,0 +1,194 @@ +window.indexedDbStorage = { + // Open the database (creates it if it doesn't exist, or upgrades if needed) + open: function (dbName = "AppDb") { + return new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 3); // Increment version to 3 + + // Handle database upgrades + request.onupgradeneeded = function (event) { + const db = event.target.result; + + let cacheStore; + + // Create 'cache' store if it does not exist + if (!db.objectStoreNames.contains('cache')) { + cacheStore = db.createObjectStore('cache', { keyPath: 'key' }); + } else { + cacheStore = event.currentTarget.transaction.objectStore('cache'); + } + + // Ensure 'tags' index exists + if (!cacheStore.indexNames.contains('tags')) { + cacheStore.createIndex('tags', 'tags', { multiEntry: true }); + } + }; + + request.onsuccess = function (event) { + resolve(event.target.result); // Resolve with the database instance + }; + + request.onerror = function (event) { + reject(event.target.error); // Reject with the error + }; + }); + }, + + // Save data to the cache store (with JSON serialization and optional tags) + saveData: function (dbName, key, value, tags = []) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readwrite'); + const store = transaction.objectStore('cache'); + const serializedValue = JSON.stringify(value); // Serialize the value + + const request = store.put({ key: key, value: serializedValue, tags: tags }); // Include tags + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }); + }, + + // Get all data by tags (array of tags) + getDataByTags: function (dbName, tags) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readonly'); // Open a readonly transaction on 'cache' + const store = transaction.objectStore('cache'); // Access the 'cache' object store + const index = store.index('tags'); // Access the 'tags' index + + const results = []; + // Fetch data for each tag + const tagRequests = tags.map(tag => { + return new Promise((tagResolve, tagReject) => { + const request = index.getAll(tag); // Retrieve all entries matching the tag + + request.onsuccess = function (event) { + const entries = event.target.result; + // Push key and deserialized value into the results array + entries.forEach(entry => { + results.push({ key: entry.key, value: entry.value }); + }); + tagResolve(); + }; + + request.onerror = function (event) { + tagReject(event.target.error); // Handle errors + }; + }); + }); + + // Combine results for all tags + Promise.all(tagRequests) + .then(() => resolve(results)) // Return the list of { key, value } + .catch(reject); + }); + }); + }, + + // Delete all data with specific tags (array of tags) + deleteDataByTags: function (dbName, tags) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readwrite'); + const store = transaction.objectStore('cache'); + const index = store.index('tags'); + + const deletePromises = []; + const tagRequests = tags.map(tag => { + return new Promise((tagResolve, tagReject) => { + const request = index.getAllKeys(tag); + + request.onsuccess = function (event) { + const keys = event.target.result; + const keyDeletes = keys.map(key => { + return new Promise((delResolve, delReject) => { + const deleteRequest = store.delete(key); + deleteRequest.onsuccess = () => delResolve(); + deleteRequest.onerror = () => delReject(deleteRequest.error); + }); + }); + deletePromises.push(...keyDeletes); + tagResolve(); + }; + + request.onerror = function (event) { + tagReject(event.target.error); + }; + }); + }); + + Promise.all(tagRequests) + .then(() => Promise.all(deletePromises)) + .then(resolve) + .catch(reject); + }); + }); + }, + + // Get data from the cache store (with JSON deserialization) + getData: function (dbName, key) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readonly'); + const store = transaction.objectStore('cache'); + const request = store.get(key); + + request.onsuccess = function (event) { + const result = event.target.result; + if (result) { + try { + resolve(JSON.parse(result.value)); // Deserialize the value + } catch (e) { + reject("Error parsing JSON"); + } + } else { + resolve(null); // No data found + } + }; + + request.onerror = function (event) { + reject(event.target.error); + }; + }); + }); + }, + + // Clear all data from the cache store + clearData: function (dbName) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readwrite'); + const store = transaction.objectStore('cache'); + const request = store.clear(); // Clear all data in the store + + request.onsuccess = function () { + resolve(); // Success, data cleared + }; + + request.onerror = function (event) { + reject(event.target.error); // Error clearing data + }; + }); + }); + }, + + // Delete a specific key from the cache store + deleteData: function (dbName, key) { + return this.open(dbName).then(db => { + return new Promise((resolve, reject) => { + const transaction = db.transaction('cache', 'readwrite'); + const store = transaction.objectStore('cache'); + const request = store.delete(key); // Delete the specified key + + request.onsuccess = function () { + resolve(); // Success, key deleted + }; + + request.onerror = function (event) { + reject(event.target.error); // Error deleting key + }; + }); + }); + } +}; diff --git a/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js b/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js index deb2a1f..dd92e33 100644 --- a/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js +++ b/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js @@ -1,11 +1,17 @@ -export function getOnlineStatus() { - return navigator.onLine; -} -export function addOnlineStatusListener(dotNetObjectRef) { - window.addEventListener('online', () => { - dotNetObjectRef.invokeMethodAsync('UpdateOnlineStatus', true); - }); - window.addEventListener('offline', () => { - dotNetObjectRef.invokeMethodAsync('UpdateOnlineStatus', false); - }); -} \ No newline at end of file + +window.onlineStatusInterop = { + getOnlineStatus: function () { + return navigator.onLine; + }, + addOnlineStatusListener: function (dotNetObjectRef) { + const onlineHandler = () => { + dotNetObjectRef.invokeMethodAsync('UpdateOnlineStatus', true); + }; + const offlineHandler = () => { + dotNetObjectRef.invokeMethodAsync('UpdateOnlineStatus', false); + }; + + window.addEventListener('online', onlineHandler); + window.addEventListener('offline', offlineHandler); + } +}; \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/js/webpushr.js b/src/CleanAspire.ClientApp/wwwroot/js/webpushr.js index ec11967..c3a80e2 100644 --- a/src/CleanAspire.ClientApp/wwwroot/js/webpushr.js +++ b/src/CleanAspire.ClientApp/wwwroot/js/webpushr.js @@ -1,16 +1,18 @@ -export function setupWebpushr(key) { - (function (w, d, s, id) { - if (typeof (w.webpushr) !== 'undefined') return; - w.webpushr = w.webpushr || function () { (w.webpushr.q = w.webpushr.q || []).push(arguments) }; - var js, fjs = d.getElementsByTagName(s)[0]; - js = d.createElement(s); js.id = id; js.async = 1; - js.src = "https://cdn.webpushr.com/app.min.js"; - fjs.parentNode.appendChild(js); - }(window, document, 'script', 'webpushr-jssdk')); +window.webpushrInterop = { + setupWebpushr: function (key) { + (function (w, d, s, id) { + if (typeof (w.webpushr) !== 'undefined') return; + w.webpushr = w.webpushr || function () { (w.webpushr.q = w.webpushr.q || []).push(arguments); }; + var js, fjs = d.getElementsByTagName(s)[0]; + js = d.createElement(s); js.id = id; js.async = 1; + js.src = "https://cdn.webpushr.com/app.min.js"; + fjs.parentNode.appendChild(js); + }(window, document, 'script', 'webpushr-jssdk')); - webpushr('setup', { 'key': key }); + webpushr('setup', { 'key': key }); - webpushr('fetch_id', function (sid) { - console.log(sid); - }); -} \ No newline at end of file + webpushr('fetch_id', function (sid) { + console.log(sid); + }); + } +}; \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/service-worker.js b/src/CleanAspire.ClientApp/wwwroot/service-worker.js index 10aafe8..66a991a 100644 --- a/src/CleanAspire.ClientApp/wwwroot/service-worker.js +++ b/src/CleanAspire.ClientApp/wwwroot/service-worker.js @@ -2,22 +2,6 @@ // This is because caching would make development more difficult (changes would not // be reflected on the first load after each change). -const CACHE_NAME = 'cache-v1'; -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll([]); - }) - ); -}); -self.addEventListener('fetch', (event) => { - console.log('Fetching:', event.request.url); - event.respondWith( - caches.match(event.request).then((response) => { - if (response) { - console.log('Serving from cache:', event.request.url); - } - return response || fetch(event.request); - }) - ); +self.addEventListener('fetch', event => { + }); diff --git a/src/CleanAspire.ClientApp/wwwroot/service-worker.published.js b/src/CleanAspire.ClientApp/wwwroot/service-worker.published.js index 1f7f543..aae59f0 100644 --- a/src/CleanAspire.ClientApp/wwwroot/service-worker.published.js +++ b/src/CleanAspire.ClientApp/wwwroot/service-worker.published.js @@ -8,7 +8,7 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event))); const cacheNamePrefix = 'offline-cache-'; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; -const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; +const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.svg$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; const offlineAssetsExclude = [ /^service-worker\.js$/ ]; // Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. @@ -29,7 +29,7 @@ async function onInstall(event) { async function onActivate(event) { console.info('Service worker: Activate'); - + const cache = await caches.open(cacheName); // Delete unused caches const cacheKeys = await caches.keys(); await Promise.all(cacheKeys diff --git a/src/CleanAspire.ClientApp/wwwroot/syncing.svg b/src/CleanAspire.ClientApp/wwwroot/syncing.svg new file mode 100644 index 0000000..e32845c --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/syncing.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file