Skip to content

Commit

Permalink
Merge pull request #19 from neozhu/feature/offline-access
Browse files Browse the repository at this point in the history
Implement Offline Mode with IndexedDB Caching for Authentication
  • Loading branch information
neozhu authored Dec 16, 2024
2 parents e343b0e + 6f71bd7 commit e7c3921
Show file tree
Hide file tree
Showing 55 changed files with 1,141 additions and 361 deletions.
1 change: 1 addition & 0 deletions CleanAspire.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path="README.md" />
</Folder>
<Folder Name="/src/">
<Project Path="src/CleanAspire.Api/CleanAspire.Api.csproj" Id="ded5e19f-db6b-c4ee-e692-cffe0619c173" />
Expand Down
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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?

Expand All @@ -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=*
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/CleanAspire.Api/CleanAspire.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="9.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.49" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.55" />
<PackageReference Include="Scrutor" Version="5.0.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="StrongGrid" Version="0.110.0" />
Expand Down
7 changes: 7 additions & 0 deletions src/CleanAspire.Api/Endpoints/ProductEndpointRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes)
})
.Produces<IEnumerable<ProductDto>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status500InternalServerError)
.WithSummary("Get all products")
.WithDescription("Returns a list of all products in the system.");

Expand All @@ -33,6 +34,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes)
.Produces<ProductDto>(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.");

Expand All @@ -41,6 +43,7 @@ public void RegisterRoutes(IEndpointRouteBuilder routes)
.Produces<ProductDto>(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.");

Expand All @@ -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.");

Expand All @@ -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<PaginatedResult<ProductDto>>(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.");
}
Expand Down
12 changes: 6 additions & 6 deletions src/CleanAspire.Api/ExceptionHandlers/ProblemExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,21 @@ public async ValueTask<bool> 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",
Expand Down Expand Up @@ -92,8 +92,8 @@ public async ValueTask<bool> 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}"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(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();
})
Expand Down
2 changes: 1 addition & 1 deletion src/CleanAspire.Application/CleanAspire.Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.0.0-preview-2" />
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.0.0-preview-3" />
<ProjectReference Include="..\CleanAspire.Domain\CleanAspire.Domain.csproj" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ public GetProductByIdQueryHandler(IApplicationDbContext dbContext)
{
throw new KeyNotFoundException($"Product with Id '{request.Id}' was not found.");
}

return product;
}
}
12 changes: 6 additions & 6 deletions src/CleanAspire.ClientApp/CleanAspire.ClientApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0 " />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Abstractions" Version="1.16.0" />
<PackageReference Include="Microsoft.Kiota.Http.HttpClientLibrary" Version="1.16.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="9.0.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.15.2" />
<PackageReference Include="Microsoft.Kiota.Serialization.Form" Version="1.16.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Json" Version="1.16.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Multipart" Version="1.16.0" />
<PackageReference Include="Microsoft.Kiota.Serialization.Text" Version="1.16.0" />
<PackageReference Include="MudBlazor" Version="8.0.0-preview.5" />
<PackageReference Include="OneOf" Version="3.0.271" />
</ItemGroup>
Expand Down
5 changes: 5 additions & 0 deletions src/CleanAspire.ClientApp/Client/.kiota/workspace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"clients": {},
"plugins": {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions src/CleanAspire.ClientApp/Components/OfflineSyncStatus.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
@inject OfflineSyncService OfflineSyncService

<div class="d-flex justify-center align-center flex-row gap-2">
@if (OfflineSyncService.CurrentStatus == SyncStatus.Idle)
{
<MudIconButton Icon="@Icons.Material.Outlined.MoreVert" Color="Color.Inherit" />
}
else if (OfflineSyncService.CurrentStatus == SyncStatus.Completed)
{
<MudText Typo="Typo.caption">@OfflineSyncService.StatusMessage</MudText>
<MudIcon>
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="45" stroke="#2ecc71" stroke-width="9" fill="none">
<animate attributeName="stroke-dasharray" from="0, 283" to="283, 283" dur="0.6s" fill="freeze" />
</circle>
<path d="M30 50 L45 65 L70 35" stroke="#2ecc71" stroke-width="10" fill="none" stroke-linecap="round" stroke-linejoin="round"
stroke-dasharray="50, 50" stroke-dashoffset="50">
<animate attributeName="stroke-dashoffset" from="50" to="0" dur="0.4s" begin="0.6s" fill="freeze" />
</path>
</svg>
</MudIcon>
}else
{
<MudText Typo="Typo.caption">@OfflineSyncService.StatusMessage</MudText>
<MudIcon>
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" transform="matrix(-1.8369701987210297e-16,-1,1,-1.8369701987210297e-16,0,0)">
<circle cx="50" cy="20" r="7" fill="#3498db">
<animateTransform attributeType="XML" attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" repeatCount="indefinite"></animateTransform>
</circle>
<circle cx="80" cy="50" r="7" fill="#e74c3c">
<animateTransform attributeType="XML" attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" begin="0.2s" repeatCount="indefinite"></animateTransform>
</circle>
<circle cx="50" cy="80" r="7" fill="#f1c40f">
<animateTransform attributeType="XML" attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" begin="0.4s" repeatCount="indefinite"></animateTransform>
</circle>
<circle cx="20" cy="50" r="7" fill="#2ecc71">
<animateTransform attributeType="XML" attributeName="transform" type="rotate" from="0 50 50" to="360 50 50" dur="1.5s" begin="0.6s" repeatCount="indefinite"></animateTransform>
</circle>
</svg>
</MudIcon>
}
</div>

@code {
protected override void OnInitialized()
{
OfflineSyncService.OnSyncStateChanged += StateHasChanged;
}

public void Dispose()
{
OfflineSyncService.OnSyncStateChanged -= StateHasChanged;
}


}
22 changes: 7 additions & 15 deletions src/CleanAspire.ClientApp/Components/WebpushrSetup.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
}
}
}
}
4 changes: 2 additions & 2 deletions src/CleanAspire.ClientApp/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,6 +43,6 @@ public static void TryAddMudBlazor(this IServiceCollection services, IConfigurat
services.AddScoped<DialogServiceHelper>();
#endregion
}

}

5 changes: 3 additions & 2 deletions src/CleanAspire.ClientApp/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/CleanAspire.ClientApp/Layout/Navbar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
}

<MudSpacer />
<MudIconButton Icon="@Icons.Material.Outlined.MoreVert" Color="Color.Inherit" />
<OfflineSyncStatus></OfflineSyncStatus>
</MudToolBar>
</MudPaper>

Expand Down
Loading

0 comments on commit e7c3921

Please sign in to comment.