diff --git a/src/CleanAspire.ClientApp/Layout/Appbar.razor b/src/CleanAspire.ClientApp/Layout/Appbar.razor index d21526d..318ce2c 100644 --- a/src/CleanAspire.ClientApp/Layout/Appbar.razor +++ b/src/CleanAspire.ClientApp/Layout/Appbar.razor @@ -2,6 +2,8 @@ @L[AppSettings.AppName] + + diff --git a/src/CleanAspire.ClientApp/Layout/UserMenu.razor b/src/CleanAspire.ClientApp/Layout/UserMenu.razor index 7bb1d32..fb5568b 100644 --- a/src/CleanAspire.ClientApp/Layout/UserMenu.razor +++ b/src/CleanAspire.ClientApp/Layout/UserMenu.razor @@ -1,21 +1,33 @@ - -@using CleanAspire.ClientApp.Services.Identity +@using CleanAspire.ClientApp.Services.Identity @using Microsoft.AspNetCore.Components.Authorization @inject IIdentityManagement IdentityManagement - +@inject OnlineStatusService OnlineStatusService +@inject LayoutService LayoutService - @if (string.IsNullOrEmpty(userModel?.AvatarUrl)) - { - @userModel?.Username?.FirstOrDefault() - } - else - { - - - - } + +
+ @if (_isOnline == false) + { +
+ } + else + { +
+ } + @if (string.IsNullOrEmpty(userModel?.AvatarUrl)) + { + @userModel?.Username?.FirstOrDefault() + } + else + { + + + + } +
+
@userModel?.Username @userModel?.Email @@ -54,10 +66,33 @@ public AuthenticationStateProvider AuthenticationStateProvider { get; set; } = null!; private ProfileResponse? userModel => UserProfileStore.Profile; + private string statusTooltip = "Loading..."; + private bool _isOnline = false; private async Task OnSignOut() { await IdentityManagement.LogoutAsync(); StateHasChanged(); } + protected override async Task OnInitializedAsync() + { + await OnlineStatusService.InitializeAsync(); + bool isOnline = await OnlineStatusService.GetOnlineStatusAsync(); + UpdateStatus(isOnline); + OnlineStatusService.OnlineStatusChanged += UpdateStatus; + await OnlineStatusService.RegisterOnlineStatusListenerAsync(); + } + private void UpdateStatus(bool isOnline) + { + // Update the Base64 image and tooltip based on the network status + statusTooltip = isOnline ? "Online" : "Offline"; + _isOnline = isOnline; + InvokeAsync(StateHasChanged); + } + public async ValueTask DisposeAsync() + { + // Unsubscribe from events and dispose of resources + OnlineStatusService.OnlineStatusChanged -= UpdateStatus; + await OnlineStatusService.DisposeAsync(); + } } diff --git a/src/CleanAspire.ClientApp/Program.cs b/src/CleanAspire.ClientApp/Program.cs index 4e23399..75f5f49 100644 --- a/src/CleanAspire.ClientApp/Program.cs +++ b/src/CleanAspire.ClientApp/Program.cs @@ -15,6 +15,7 @@ using Microsoft.Kiota.Serialization.Multipart; using CleanAspire.ClientApp.Services; using Microsoft.Extensions.DependencyInjection; +using CleanAspire.ClientApp.Services.JsInterop; var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -25,6 +26,7 @@ builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var clientAppSettings = builder.Configuration.GetSection(ClientAppSettings.KEY).Get(); builder.Services.AddSingleton(clientAppSettings!); diff --git a/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs b/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs new file mode 100644 index 0000000..b8bd105 --- /dev/null +++ b/src/CleanAspire.ClientApp/Services/JsInterop/OnlineStatusService.cs @@ -0,0 +1,65 @@ +// 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/Themes/Theme.cs b/src/CleanAspire.ClientApp/Themes/Theme.cs index ccd139f..16cf629 100644 --- a/src/CleanAspire.ClientApp/Themes/Theme.cs +++ b/src/CleanAspire.ClientApp/Themes/Theme.cs @@ -65,7 +65,7 @@ public static MudTheme ApplicationTheme() PaletteDark = new() { // **Primary Colors** - Primary = "#bd93ff", // Deep blue, used for highlights and key elements + Primary = "#8a2be2", // Deep blue, used for highlights and key elements Secondary = "#B0BEC5", // Light gray, secondary text color // **Background and Surface** diff --git a/src/CleanAspire.ClientApp/wwwroot/css/app.css b/src/CleanAspire.ClientApp/wwwroot/css/app.css index bfce92a..c335000 100644 --- a/src/CleanAspire.ClientApp/wwwroot/css/app.css +++ b/src/CleanAspire.ClientApp/wwwroot/css/app.css @@ -136,6 +136,105 @@ padding: 0rem !important; } +.network-status-indicator { + width: 48px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto; /* Center horizontally if needed */ + box-sizing: border-box; +} + +.avatar-container { + position: relative; + width: 40px; /* Fixed container width, same as avatar */ + height: 40px; /* Fixed container height, same as avatar */ +} + +/* Red rotating border (common style) */ +.rotating-border { + position: absolute; + top: -4px; + left: -4px; + width: 48px; + height: 48px; + border: 3px solid transparent; /* Default transparent */ + border-radius: 50%; + animation: spin 2s linear infinite; /* Rotating animation */ + z-index: 1; + box-sizing: border-box; +} + + /* Red border in dark mode */ + .rotating-border.dark-mode { + border-top: 3px solid rgba(255, 0, 0, 0.8); /* Dark red border */ + box-shadow: 0 0 8px rgba(255, 0, 0, 0.6), 0 0 12px rgba(255, 50, 50, 0.4); /* Enhanced shadow */ + } + + /* Red border in light mode */ + .rotating-border.light-mode { + border-top: 3px solid rgba(255, 50, 50, 0.8); /* Light red border */ + box-shadow: 0 0 6px rgba(255, 50, 50, 0.5), 0 0 10px rgba(255, 100, 100, 0.3); /* Soft shadow */ + } + +/* Dynamic green border (common style) */ +.online-border { + position: absolute; + top: -4px; + left: -4px; + width: 48px; + height: 48px; + border-radius: 50%; + animation: green-pulse 2s infinite; /* Breathing animation */ + z-index: 1; + box-sizing: border-box; +} + + /* Green border in dark mode */ + .online-border.dark-mode { + border: 3px solid rgba(0, 128, 0, 1); /* Dark green border */ + box-shadow: 0 0 8px rgba(0, 128, 0, 0.6), 0 0 12px rgba(0, 255, 0, 0.4); + } + + /* Green border in light mode */ + .online-border.light-mode { + border: 3px solid rgba(0, 200, 0, 1); /* Light green border */ + box-shadow: 0 0 6px rgba(0, 200, 0, 0.5), 0 0 10px rgba(0, 255, 0, 0.3); + } +/* Breathing animation */ +@keyframes green-pulse { + 0% { + box-shadow: 0 0 8px rgba(0, 128, 0, 0.6), 0 0 12px rgba(0, 255, 0, 0.4); + transform: scale(1); + } + + 50% { + box-shadow: 0 0 10px rgba(0, 128, 0, 0.8), 0 0 14px rgba(0, 255, 0, 0.5); + transform: scale(1.03); /* Reduce scaling amplitude */ + } + + 100% { + box-shadow: 0 0 8px rgba(0, 128, 0, 0.6), 0 0 12px rgba(0, 255, 0, 0.4); + transform: scale(1); + } +} + +MudAvatar { + position: relative; + z-index: 2; /* Ensure avatar is above the border */ +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + .blazor-error-boundary { background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; diff --git a/src/CleanAspire.ClientApp/wwwroot/green-point.svg b/src/CleanAspire.ClientApp/wwwroot/green-point.svg new file mode 100644 index 0000000..b0ff9b9 --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/green-point.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js b/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js new file mode 100644 index 0000000..deb2a1f --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/js/onlinestatus.js @@ -0,0 +1,11 @@ +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 diff --git a/src/CleanAspire.ClientApp/wwwroot/loading.svg b/src/CleanAspire.ClientApp/wwwroot/loading.svg new file mode 100644 index 0000000..4f6c6d0 --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/loading.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/src/CleanAspire.ClientApp/wwwroot/red-point.svg b/src/CleanAspire.ClientApp/wwwroot/red-point.svg new file mode 100644 index 0000000..818240a --- /dev/null +++ b/src/CleanAspire.ClientApp/wwwroot/red-point.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 fe614da..10aafe8 100644 --- a/src/CleanAspire.ClientApp/wwwroot/service-worker.js +++ b/src/CleanAspire.ClientApp/wwwroot/service-worker.js @@ -1,4 +1,23 @@ // In development, always fetch from the network and do not enable offline support. // This is because caching would make development more difficult (changes would not // be reflected on the first load after each change). -self.addEventListener('fetch', () => { }); + +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); + }) + ); +});