diff --git a/src/AppServices.Core/EntityServices/Staff/StaffService.cs b/src/AppServices.Core/EntityServices/Staff/StaffService.cs index 2d34a22aa..c53c6dba2 100644 --- a/src/AppServices.Core/EntityServices/Staff/StaffService.cs +++ b/src/AppServices.Core/EntityServices/Staff/StaffService.cs @@ -31,6 +31,9 @@ public interface IStaffService : IDisposable, IAsyncDisposable Task> GetAppRolesAsync(string id); Task UpdateRolesAsync(string id, Dictionary roles); Task UpdateAsync(string id, StaffUpdateDto resource); + Task GetPreferencesAsync(); + Task UpdateThemePreferenceAsync(ThemePreference theme); + } public sealed class StaffService( @@ -175,7 +178,21 @@ public async Task UpdateAsync(string id, StaffUpdateDto resource await cache.RemoveByTagAsync(CachedStaffLists).ConfigureAwait(false); return await userManager.UpdateAsync(user).ConfigureAwait(false); } + public async Task GetPreferencesAsync() + { + var user = await userService.GetCurrentUserAsync() + ?? throw new CurrentUserNotFoundException(); + return user.Preferences; + } + public async Task UpdateThemePreferenceAsync(ThemePreference theme) + { + var user = await userService.GetCurrentUserAsync() + ?? throw new CurrentUserNotFoundException(); + + user.Preferences.Theme = theme; + await userManager.UpdateAsync(user).ConfigureAwait(false); + } #region IDisposable, IAsyncDisposable public void Dispose() diff --git a/src/Domain.Core/Entities/ApplicationUser.cs b/src/Domain.Core/Entities/ApplicationUser.cs index 73089f679..74a2c8859 100644 --- a/src/Domain.Core/Entities/ApplicationUser.cs +++ b/src/Domain.Core/Entities/ApplicationUser.cs @@ -1,4 +1,4 @@ -using GaEpd.AppLibrary.Extensions; +using GaEpd.AppLibrary.Extensions; using Microsoft.AspNetCore.Identity; using System.Text; @@ -37,6 +37,8 @@ public sealed class ApplicationUser : IdentityUser, IEntity public string SortableFullName => new[] { FamilyName, GivenName }.ConcatWithSeparator(", "); public string FullName => new[] { GivenName, FamilyName }.ConcatWithSeparator(); + public UserPreferences Preferences { get; set; } = new(); + public string SortableNameWithInactive { get diff --git a/src/Domain.Core/Entities/UserPreferences.cs b/src/Domain.Core/Entities/UserPreferences.cs new file mode 100644 index 000000000..21ed9f63b --- /dev/null +++ b/src/Domain.Core/Entities/UserPreferences.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace AirWeb.Domain.Core.Entities; + +public class UserPreferences +{ + public ThemePreference Theme { get; set; } = ThemePreference.Dark; +} +public enum ThemePreference +{ + Auto = 0, + Light = 1, + Dark = 2, +} diff --git a/src/WebApp/Pages/Account/Edit.cshtml b/src/WebApp/Pages/Account/Edit.cshtml index 2502512d9..3287a70ce 100644 --- a/src/WebApp/Pages/Account/Edit.cshtml +++ b/src/WebApp/Pages/Account/Edit.cshtml @@ -1,4 +1,4 @@ -@page +@page @using AirWeb.WebApp.Pages.Shared.EditorTemplates @model EditModel @{ @@ -24,7 +24,7 @@ @Html.EditorFor(m => m.UpdateStaff.OfficeId, EditorTemplate.Select, additionalViewData: new { Items = Model.OfficeSelectList }) - + diff --git a/src/WebApp/Pages/Account/Index.cshtml b/src/WebApp/Pages/Account/Index.cshtml index 9d3423d0f..223b9d844 100644 --- a/src/WebApp/Pages/Account/Index.cshtml +++ b/src/WebApp/Pages/Account/Index.cshtml @@ -1,5 +1,6 @@ @page @using AirWeb.WebApp.Pages.Shared.DisplayTemplates +@using AirWeb.Domain.Core.Entities @model AccountIndexModel @{ ViewData["Title"] = "Account Profile"; @@ -27,6 +28,50 @@
Office
@Html.DisplayFor(m => m.OfficeName, DisplayTemplate.TextOrPlaceholder, additionalViewData: new { Placeholder = DefaultText.PlaceholderNone })
+ +
Theme
+
+
+ +
+ + + + + + + + + + + + + +
+ +
+
diff --git a/src/WebApp/Pages/Account/Index.cshtml.cs b/src/WebApp/Pages/Account/Index.cshtml.cs index 653471aa9..3d6afc077 100644 --- a/src/WebApp/Pages/Account/Index.cshtml.cs +++ b/src/WebApp/Pages/Account/Index.cshtml.cs @@ -1,8 +1,10 @@ -using AirWeb.AppServices.Core.AuthorizationServices; +using AirWeb.AppServices.Core.AuthorizationServices; using AirWeb.AppServices.Core.EntityServices.Staff; using AirWeb.AppServices.Core.EntityServices.Staff.Dto; using AirWeb.Domain.Core.AppRoles; +using AirWeb.Domain.Core.Entities; using AirWeb.WebApp.Models; +using DocumentFormat.OpenXml.Presentation; namespace AirWeb.WebApp.Pages.Account; @@ -10,6 +12,7 @@ namespace AirWeb.WebApp.Pages.Account; public class AccountIndexModel : PageModel { public StaffViewDto DisplayStaff { get; private set; } = null!; + public UserPreferences Preferences { get; private set; } = null!; public string? OfficeName => DisplayStaff.Office?.Name; public IReadOnlyList Roles { get; private set; } = null!; @@ -17,6 +20,7 @@ public async Task OnGetAsync([FromServices] IStaffService staffSe { DisplayStaff = await staffService.GetCurrentUserAsync(); Roles = await staffService.GetAppRolesAsync(DisplayStaff.Id); + Preferences = await staffService.GetPreferencesAsync(); if (DisplayStaff.Office is null) TempData.AddDisplayMessage(DisplayMessage.AlertContext.Warning, @@ -24,4 +28,9 @@ public async Task OnGetAsync([FromServices] IStaffService staffSe return Page(); } + public async Task OnPostAsync(ThemePreference theme, [FromServices] IStaffService staffservice) + { + await staffservice.UpdateThemePreferenceAsync(theme); + return RedirectToPage(); + } } diff --git a/src/WebApp/Pages/Shared/Components/MainMenu/Default.cshtml b/src/WebApp/Pages/Shared/Components/MainMenu/Default.cshtml index af61557a8..9ae0bb1bd 100644 --- a/src/WebApp/Pages/Shared/Components/MainMenu/Default.cshtml +++ b/src/WebApp/Pages/Shared/Components/MainMenu/Default.cshtml @@ -1,4 +1,4 @@ -@model MainMenuViewComponent.MenuParams +@model MainMenuViewComponent.MenuParams
diff --git a/src/WebApp/Pages/Shared/_Layout.cshtml b/src/WebApp/Pages/Shared/_Layout.cshtml index dabf822cd..177707875 100644 --- a/src/WebApp/Pages/Shared/_Layout.cshtml +++ b/src/WebApp/Pages/Shared/_Layout.cshtml @@ -1,8 +1,16 @@ +@inject AirWeb.AppServices.Core.EntityServices.Staff.IStaffService StaffService @{ var displayMessages = TempData.GetDisplayMessages(); + var themePreference = "auto"; + if (User.Identity?.IsAuthenticated == true) + { + var preferences = await StaffService.GetPreferencesAsync(); + themePreference = preferences.Theme.ToString().ToLower(); + } } - + diff --git a/src/WebApp/wwwroot/js/color-modes.js b/src/WebApp/wwwroot/js/color-modes.js index ead60d519..25c6589e1 100644 --- a/src/WebApp/wwwroot/js/color-modes.js +++ b/src/WebApp/wwwroot/js/color-modes.js @@ -1,4 +1,4 @@ -/*! +/*! * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) * Copyright 2011-2025 The Bootstrap Authors * Licensed under the Creative Commons Attribution 3.0 Unported License. @@ -7,21 +7,14 @@ (() => { 'use strict' - const getStoredTheme = () => localStorage.getItem('theme') - const setStoredTheme = theme => localStorage.setItem('theme', theme) - const getPreferredTheme = () => { - const storedTheme = getStoredTheme() - if (storedTheme) { - return storedTheme - } - - return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + return document.documentElement.dataset.themePreference ?? 'auto'; } const setTheme = theme => { if (theme === 'auto') { - document.documentElement.dataset.bsTheme = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + document.documentElement.dataset.bsTheme = + globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } else { document.documentElement.dataset.bsTheme = theme } @@ -29,54 +22,19 @@ setTheme(getPreferredTheme()) - const showActiveTheme = (theme, focus = false) => { - const themeSwitcher = document.querySelector('#bd-theme') - - if (!themeSwitcher) { - return - } - - const themeSwitcherText = document.querySelector('#bd-theme-text') - const activeThemeIcon = document.querySelector('.theme-icon-active use') - const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`) - const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href') - - document.querySelectorAll('[data-bs-theme-value]').forEach(element => { - element.classList.remove('active') - element.setAttribute('aria-pressed', 'false') - element.querySelector('.theme-checkmark').classList.add('d-none') - }) - - btnToActive.classList.add('active') - btnToActive.setAttribute('aria-pressed', 'true') - btnToActive.querySelector('.theme-checkmark').classList.remove('d-none') - activeThemeIcon.setAttribute('href', svgOfActiveBtn) - const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})` - themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) - themeSwitcher.setAttribute('title', themeSwitcherLabel) - - if (focus) { - themeSwitcher.focus() - } - } - globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - const storedTheme = getStoredTheme() - if (storedTheme !== 'light' && storedTheme !== 'dark') { - setTheme(getPreferredTheme()) + if (getPreferredTheme() === 'auto') { + setTheme('auto') } }) globalThis.addEventListener('DOMContentLoaded', () => { - showActiveTheme(getPreferredTheme()) - - document.querySelectorAll('[data-bs-theme-value]') - .forEach(toggle => { - toggle.addEventListener('click', () => { - const theme = toggle.dataset.bsThemeValue - setStoredTheme(theme) - setTheme(theme) - showActiveTheme(theme, true) + document.querySelectorAll('input[name="theme"]') + .forEach(radio => { + radio.addEventListener('change', () => { + const theme = radio.value; + setTheme(theme); + document.querySelector('#themeForm').submit(); }) }) })