From e9a9dc06d71f4fc4aa91328acaaa41b0721b6241 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Thu, 30 Jul 2020 23:50:51 -0400 Subject: [PATCH] Updating Blazor Admin (#442) * Updating Blazor services * Adding Settings and Refactoring Services * WIP - Fighting with DI * Configuring dependencies in both Web Startup and BlazorAdmin Program.cs has them working again. * Everything works; need to optimize calls to ListBrands * LocalStorageBrandService decorator working * Added cache duration of 1 minute * Refactoring to reduce token storage Fixed issue with dropdowns binding to int * Remove token stuff from login; moved to CustomAuthStateProvider * Migrated CatalogTypes to separate service Implemented cache decorator * Ardalis/blazor refactor (#440) * 1. Migrate CatalogItemServices -> CatalogItemService. 3. Add caching to CatalogItemService. * change to $"Loading {key} from local storage" ? * docker settings added. (#441) * docker settings added. * InDocker Removed * InDocker removed from web startup. * removed unused using * no reload list if close without save * startup patch for localhost * file name fixed * removed docker from launchSettings. * Configure logging via appsettings Co-authored-by: Shady Nagy --- docker-compose.override.yml | 6 +- src/BlazorAdmin/BlazorAdmin.csproj | 1 + src/BlazorAdmin/CustomAuthStateProvider.cs | 33 +++-- .../Pages/CatalogItemPage/Create.razor | 8 +- .../Pages/CatalogItemPage/Delete.razor | 14 +- .../Pages/CatalogItemPage/Details.razor | 13 +- .../Pages/CatalogItemPage/Edit.razor | 19 +-- .../Pages/CatalogItemPage/List.razor | 13 +- .../Pages/CatalogItemPage/List.razor.cs | 22 +++- src/BlazorAdmin/Program.cs | 43 +++++-- src/BlazorAdmin/Services/AuthService.cs | 120 +----------------- src/BlazorAdmin/Services/CacheEntry.cs | 19 +++ .../CachedCatalogBrandServiceDecorator.cs | 59 +++++++++ .../CachedCatalogItemServiceDecorator.cs | 114 +++++++++++++++++ .../CachedCatalogTypeServiceDecorator.cs | 57 +++++++++ .../Services/CatalogBrandService.cs | 41 ++++++ .../CatalogBrandServices/List.CatalogBrand.cs | 8 -- .../Services/CatalogBrandServices/List.cs | 33 ----- .../Services/CatalogItemService.cs | 102 +++++++++++++++ .../Create.CreateCatalogItemResult.cs | 7 - .../Services/CatalogItemServices/Create.cs | 19 --- .../Delete.DeleteCatalogItemResult.cs | 7 - .../Services/CatalogItemServices/Delete.cs | 19 --- .../Services/CatalogItemServices/Edit.cs | 19 --- .../GetById.GetByIdCatalogItemResult.cs | 7 - .../Services/CatalogItemServices/GetById.cs | 19 --- .../Services/CatalogItemServices/ListPaged.cs | 21 --- .../Services/CatalogTypeService.cs | 40 ++++++ .../CatalogTypeServices/List.CatalogType.cs | 8 -- .../Services/CatalogTypeServices/List.cs | 33 ----- src/BlazorAdmin/Services/HttpService.cs | 8 +- src/BlazorAdmin/ServicesConfiguration.cs | 21 ++- src/BlazorAdmin/Shared/CustomInputSelect.cs | 38 ++++++ src/BlazorAdmin/Shared/MainLayout.razor | 8 +- src/BlazorAdmin/Shared/NavMenu.razor | 32 +++-- src/BlazorAdmin/_Imports.razor | 5 +- .../wwwroot/appsettings.Docker.json | 6 + src/BlazorAdmin/wwwroot/appsettings.json | 14 ++ .../wwwroot/sample-data/weather.json | 27 ---- src/BlazorShared/Authorization/Constants.cs | 18 --- src/BlazorShared/Authorization/UserInfo.cs | 1 + src/BlazorShared/BaseUrlConfiguration.cs | 10 ++ src/BlazorShared/BlazorShared.csproj | 4 + .../Interfaces/ICatalogBrandService.cs | 12 ++ .../Interfaces/ICatalogItemService.cs | 16 +++ .../Interfaces/ICatalogTypeService.cs | 12 ++ src/BlazorShared/Models/CatalogBrand.cs | 6 + .../Models/CatalogBrandResponse.cs} | 4 +- .../Models}/CatalogItem.cs | 21 +-- src/BlazorShared/Models/CatalogType.cs | 6 + .../Models/CatalogTypeResponse.cs} | 4 +- .../Models/CreateCatalogItemRequest.cs} | 2 +- .../Models/CreateCatalogItemResponse.cs | 7 + .../Models/DeleteCatalogItemResponse.cs | 7 + .../Models/EditCatalogItemResponse.cs} | 2 +- src/BlazorShared/Models/LookupData.cs | 8 ++ .../Models/PagedCatalogItemResponse.cs} | 4 +- src/PublicApi/CatalogItemEndpoints/Create.cs | 4 +- src/PublicApi/Program.cs | 8 ++ src/PublicApi/Properties/launchSettings.json | 7 - src/PublicApi/Startup.cs | 17 ++- src/PublicApi/appsettings.Development.json | 4 + src/PublicApi/appsettings.Docker.json | 17 +++ src/PublicApi/appsettings.json | 4 + .../Identity/Pages/Account/Login.cshtml.cs | 17 +-- .../Pages/_ValidationScriptsPartial.cshtml | 4 +- src/Web/Controllers/UserController.cs | 18 ++- src/Web/Pages/Admin/Index.cshtml | 4 +- src/Web/Program.cs | 8 ++ src/Web/Startup.cs | 26 ++-- src/Web/Views/Shared/_Layout.cshtml | 4 +- .../Shared/_ValidationScriptsPartial.cshtml | 2 +- src/Web/appsettings.Development.json | 4 + src/Web/appsettings.Docker.json | 17 +++ src/Web/appsettings.json | 4 + src/Web/wwwroot/css/site.min.css | 2 +- src/Web/wwwroot/images/products/5.jpg | Bin 0 -> 27468 bytes 77 files changed, 865 insertions(+), 533 deletions(-) create mode 100644 src/BlazorAdmin/Services/CacheEntry.cs create mode 100644 src/BlazorAdmin/Services/CachedCatalogBrandServiceDecorator.cs create mode 100644 src/BlazorAdmin/Services/CachedCatalogItemServiceDecorator.cs create mode 100644 src/BlazorAdmin/Services/CachedCatalogTypeServiceDecorator.cs create mode 100644 src/BlazorAdmin/Services/CatalogBrandService.cs delete mode 100644 src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrand.cs delete mode 100644 src/BlazorAdmin/Services/CatalogBrandServices/List.cs create mode 100644 src/BlazorAdmin/Services/CatalogItemService.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemResult.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/Create.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/Delete.DeleteCatalogItemResult.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/Delete.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/Edit.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/GetById.GetByIdCatalogItemResult.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/GetById.cs delete mode 100644 src/BlazorAdmin/Services/CatalogItemServices/ListPaged.cs create mode 100644 src/BlazorAdmin/Services/CatalogTypeService.cs delete mode 100644 src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogType.cs delete mode 100644 src/BlazorAdmin/Services/CatalogTypeServices/List.cs create mode 100644 src/BlazorAdmin/Shared/CustomInputSelect.cs create mode 100644 src/BlazorAdmin/wwwroot/appsettings.Docker.json create mode 100644 src/BlazorAdmin/wwwroot/appsettings.json delete mode 100644 src/BlazorAdmin/wwwroot/sample-data/weather.json create mode 100644 src/BlazorShared/BaseUrlConfiguration.cs create mode 100644 src/BlazorShared/Interfaces/ICatalogBrandService.cs create mode 100644 src/BlazorShared/Interfaces/ICatalogItemService.cs create mode 100644 src/BlazorShared/Interfaces/ICatalogTypeService.cs create mode 100644 src/BlazorShared/Models/CatalogBrand.cs rename src/{BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrandResult.cs => BlazorShared/Models/CatalogBrandResponse.cs} (62%) rename src/{BlazorAdmin/Services/CatalogItemServices => BlazorShared/Models}/CatalogItem.cs (79%) create mode 100644 src/BlazorShared/Models/CatalogType.cs rename src/{BlazorAdmin/Services/CatalogTypeServices/List.CatalogTypeResult.cs => BlazorShared/Models/CatalogTypeResponse.cs} (62%) rename src/{BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemRequest.cs => BlazorShared/Models/CreateCatalogItemRequest.cs} (94%) create mode 100644 src/BlazorShared/Models/CreateCatalogItemResponse.cs create mode 100644 src/BlazorShared/Models/DeleteCatalogItemResponse.cs rename src/{BlazorAdmin/Services/CatalogItemServices/Edit.EditCatalogItemResult.cs => BlazorShared/Models/EditCatalogItemResponse.cs} (70%) create mode 100644 src/BlazorShared/Models/LookupData.cs rename src/{BlazorAdmin/Services/CatalogItemServices/ListPaged.PagedCatalogItemResult.cs => BlazorShared/Models/PagedCatalogItemResponse.cs} (67%) create mode 100644 src/PublicApi/appsettings.Docker.json create mode 100644 src/Web/appsettings.Docker.json create mode 100644 src/Web/wwwroot/images/products/5.jpg diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 32b0faa..dad72a5 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -2,9 +2,8 @@ version: '3.4' services: eshopwebmvc: environment: - - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_ENVIRONMENT=Docker - ASPNETCORE_URLS=http://+:80 - - DOTNET_RUNNING_IN_CONTAINER=true ports: - "5106:80" volumes: @@ -12,9 +11,8 @@ services: - ~/.microsoft/usersecrets:/root/.microsoft/usersecrets:ro eshoppublicapi: environment: - - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_ENVIRONMENT=Docker - ASPNETCORE_URLS=http://+:80 - - DOTNET_RUNNING_IN_CONTAINER=true ports: - "5200:80" volumes: diff --git a/src/BlazorAdmin/BlazorAdmin.csproj b/src/BlazorAdmin/BlazorAdmin.csproj index f02d879..c41d0af 100644 --- a/src/BlazorAdmin/BlazorAdmin.csproj +++ b/src/BlazorAdmin/BlazorAdmin.csproj @@ -14,6 +14,7 @@ + diff --git a/src/BlazorAdmin/CustomAuthStateProvider.cs b/src/BlazorAdmin/CustomAuthStateProvider.cs index af6092e..bdb8f9d 100644 --- a/src/BlazorAdmin/CustomAuthStateProvider.cs +++ b/src/BlazorAdmin/CustomAuthStateProvider.cs @@ -1,31 +1,41 @@ -using System; -using BlazorAdmin.Services; +using BlazorAdmin.Services; +using BlazorShared.Authorization; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Logging; +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using BlazorShared.Authorization; namespace BlazorAdmin { public class CustomAuthStateProvider : AuthenticationStateProvider { + // TODO: Get Default Cache Duration from Config private static readonly TimeSpan UserCacheRefreshInterval = TimeSpan.FromSeconds(60); private readonly AuthService _authService; + private readonly HttpClient _httpClient; private readonly ILogger _logger; private DateTimeOffset _userLastCheck = DateTimeOffset.FromUnixTimeSeconds(0); private ClaimsPrincipal _cachedUser = new ClaimsPrincipal(new ClaimsIdentity()); - public CustomAuthStateProvider(AuthService authService, ILogger logger) + public CustomAuthStateProvider(AuthService authService, + HttpClient httpClient, + ILogger logger) { _authService = authService; + _httpClient = httpClient; _logger = logger; } - public override async Task GetAuthenticationStateAsync() => - new AuthenticationState(await GetUser(useCache: true)); + public override async Task GetAuthenticationStateAsync() + { + return new AuthenticationState(await GetUser(useCache: true)); + } private async ValueTask GetUser(bool useCache = false) { @@ -47,16 +57,17 @@ namespace BlazorAdmin try { - user = await _authService.GetTokenFromController(); + _logger.LogInformation("Fetching user details from web api."); + user = await _httpClient.GetFromJsonAsync("User"); } catch (Exception exc) { _logger.LogWarning(exc, "Fetching user failed."); } - + if (user == null || !user.IsAuthenticated) { - return new ClaimsPrincipal(new ClaimsIdentity()); + return null; } var identity = new ClaimsIdentity( @@ -72,6 +83,8 @@ namespace BlazorAdmin } } + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", user.Token); + return new ClaimsPrincipal(identity); } } diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/Create.razor b/src/BlazorAdmin/Pages/CatalogItemPage/Create.razor index 101f7fd..be47dae 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/Create.razor +++ b/src/BlazorAdmin/Pages/CatalogItemPage/Create.razor @@ -1,6 +1,7 @@ @inject ILogger Logger @inject AuthService Auth @inject IJSRuntime JSRuntime +@inject ICatalogItemService CatalogItemService @inherits BlazorAdmin.Helpers.BlazorComponent @@ -130,7 +131,7 @@ public IEnumerable Types { get; set; } [Parameter] - public EventCallback OnCloseClick { get; set; } + public EventCallback OnSaveClick { get; set; } private string LoadPicture => string.IsNullOrEmpty(_item.PictureBase64) ? string.Empty : $"data:image/png;base64, {_item.PictureBase64}"; private bool HasPicture => !string.IsNullOrEmpty(_item.PictureBase64); @@ -142,8 +143,8 @@ private async Task CreateClick() { - await new BlazorAdmin.Services.CatalogItemServices.Create(Auth).HandleAsync(_item); - await OnCloseClick.InvokeAsync(null); + await CatalogItemService.Create(_item); + await OnSaveClick.InvokeAsync(null); await Close(); } @@ -172,7 +173,6 @@ _modalDisplay = "none"; _modalClass = ""; _showCreateModal = false; - await OnCloseClick.InvokeAsync(null); } private async Task AddFile(IFileListEntry[] files) diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/Delete.razor b/src/BlazorAdmin/Pages/CatalogItemPage/Delete.razor index e1a9c78..4afb1eb 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/Delete.razor +++ b/src/BlazorAdmin/Pages/CatalogItemPage/Delete.razor @@ -1,6 +1,7 @@ @inject ILogger Logger @inject AuthService Auth @inject IJSRuntime JSRuntime +@inject ICatalogItemService CatalogItemService @inherits BlazorAdmin.Helpers.BlazorComponent @@ -50,7 +51,7 @@
- @Services.CatalogBrandServices.List.GetBrandName(Brands, _item.CatalogBrandId) + @_item.CatalogBrand
@@ -58,7 +59,7 @@
- @Services.CatalogTypeServices.List.GetTypeName(Types, _item.CatalogTypeId) + @_item.CatalogType
Price @@ -97,7 +98,7 @@ public IEnumerable Types { get; set; } [Parameter] - public EventCallback OnCloseClick { get; set; } + public EventCallback OnSaveClick { get; set; } private bool HasPicture => !string.IsNullOrEmpty(_item.PictureUri); private string _modalDisplay = "none;"; @@ -109,9 +110,9 @@ { // TODO: Add some kind of "are you sure" check before this - await new BlazorAdmin.Services.CatalogItemServices.Delete(Auth).HandleAsync(id); + await CatalogItemService.Delete(id); - await OnCloseClick.InvokeAsync(null); + await OnSaveClick.InvokeAsync(null); await Close(); } @@ -121,7 +122,7 @@ await new Css(JSRuntime).HideBodyOverflow(); - _item = await new GetById(Auth).HandleAsync(id); + _item = await CatalogItemService.GetById(id); _modalDisplay = "block;"; _modalClass = "Show"; @@ -136,6 +137,5 @@ _modalDisplay = "none"; _modalClass = ""; _showDeleteModal = false; - await OnCloseClick.InvokeAsync(null); } } diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/Details.razor b/src/BlazorAdmin/Pages/CatalogItemPage/Details.razor index fb3d518..4533f9b 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/Details.razor +++ b/src/BlazorAdmin/Pages/CatalogItemPage/Details.razor @@ -1,6 +1,7 @@ @inject ILogger
Logger @inject AuthService Auth @inject IJSRuntime JSRuntime +@inject ICatalogItemService CatalogItemService @inherits BlazorAdmin.Helpers.BlazorComponent @@ -53,7 +54,7 @@
- @Services.CatalogBrandServices.List.GetBrandName(Brands, _item.CatalogBrandId) + @_item.CatalogBrand
@@ -61,7 +62,7 @@
- @Services.CatalogTypeServices.List.GetTypeName(Types, _item.CatalogTypeId) + @_item.CatalogType
Price @@ -108,10 +109,10 @@ private bool _showDetailsModal = false; private CatalogItem _item = new CatalogItem(); - public void EditClick() + public async Task EditClick() { - OnEditClick.InvokeAsync(_item.Id); - Close(); + await OnEditClick.InvokeAsync(_item.Id); + await Close(); } public async Task Open(int id) @@ -121,7 +122,7 @@ await new Css(JSRuntime).HideBodyOverflow(); - _item = await new GetById(Auth).HandleAsync(id); + _item = await CatalogItemService.GetById(id); _modalDisplay = "block;"; _modalClass = "Show"; diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/Edit.razor b/src/BlazorAdmin/Pages/CatalogItemPage/Edit.razor index 101200a..2fa0214 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/Edit.razor +++ b/src/BlazorAdmin/Pages/CatalogItemPage/Edit.razor @@ -1,6 +1,7 @@ @inject ILogger Logger @inject AuthService Auth @inject IJSRuntime JSRuntime +@inject ICatalogItemService CatalogItemService @inherits BlazorAdmin.Helpers.BlazorComponent @@ -54,12 +55,12 @@
- + @foreach (var brand in Brands) { - + } - +
@@ -67,12 +68,12 @@
- + @foreach (var type in Types) { } - +
@@ -130,7 +131,7 @@ public IEnumerable Types { get; set; } [Parameter] - public EventCallback OnCloseClick { get; set; } + public EventCallback OnSaveClick { get; set; } private string LoadPicture => string.IsNullOrEmpty(_item.PictureBase64) ? string.IsNullOrEmpty(_item.PictureUri) ? string.Empty : $"{_item.PictureUri}" : $"data:image/png;base64, {_item.PictureBase64}"; private bool HasPicture => !(string.IsNullOrEmpty(_item.PictureBase64) && string.IsNullOrEmpty(_item.PictureUri)); @@ -142,7 +143,8 @@ private async Task SaveClick() { - await new BlazorAdmin.Services.CatalogItemServices.Edit(Auth).HandleAsync(_item); + await CatalogItemService.Edit(_item); + await OnSaveClick.InvokeAsync(null); await Close(); } @@ -152,7 +154,7 @@ await new Css(JSRuntime).HideBodyOverflow(); - _item = await new GetById(Auth).HandleAsync(id); + _item = await CatalogItemService.GetById(id); _modalDisplay = "block;"; _modalClass = "Show"; @@ -168,7 +170,6 @@ _modalDisplay = "none"; _modalClass = ""; _showEditModal = false; - await OnCloseClick.InvokeAsync(null); } private async Task ChangeFile(IFileListEntry[] files) diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/List.razor b/src/BlazorAdmin/Pages/CatalogItemPage/List.razor index cae60cb..2672f0e 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/List.razor +++ b/src/BlazorAdmin/Pages/CatalogItemPage/List.razor @@ -1,9 +1,6 @@ @page "/admin" @attribute [Authorize(Roles = BlazorShared.Authorization.Constants.Roles.ADMINISTRATORS)] @inject AuthService Auth -@inject BlazorAdmin.Services.CatalogItemServices.ListPaged CatalogItemListPaged -@inject BlazorAdmin.Services.CatalogTypeServices.List TypeList -@inject BlazorAdmin.Services.CatalogBrandServices.List BrandList @inherits BlazorAdmin.Helpers.BlazorComponent @namespace BlazorAdmin.Pages.CatalogItemPage @@ -42,8 +39,8 @@ else - @Services.CatalogTypeServices.List.GetTypeName(catalogTypes, item.CatalogTypeId) - @Services.CatalogBrandServices.List.GetBrandName(catalogBrands, item.CatalogBrandId) + @item.CatalogType + @item.CatalogBrand @item.Id @item.Name @item.Description @@ -63,7 +60,7 @@ else
- - - + + + } diff --git a/src/BlazorAdmin/Pages/CatalogItemPage/List.razor.cs b/src/BlazorAdmin/Pages/CatalogItemPage/List.razor.cs index 18d434c..9b12a73 100644 --- a/src/BlazorAdmin/Pages/CatalogItemPage/List.razor.cs +++ b/src/BlazorAdmin/Pages/CatalogItemPage/List.razor.cs @@ -1,14 +1,22 @@ using BlazorAdmin.Helpers; -using BlazorAdmin.Services.CatalogBrandServices; -using BlazorAdmin.Services.CatalogItemServices; -using BlazorAdmin.Services.CatalogTypeServices; using System.Collections.Generic; using System.Threading.Tasks; +using BlazorShared.Interfaces; +using BlazorShared.Models; namespace BlazorAdmin.Pages.CatalogItemPage { public partial class List : BlazorComponent { + [Microsoft.AspNetCore.Components.Inject] + public ICatalogItemService CatalogItemService { get; set; } + + [Microsoft.AspNetCore.Components.Inject] + public ICatalogBrandService CatalogBrandService { get; set; } + + [Microsoft.AspNetCore.Components.Inject] + public ICatalogTypeService CatalogTypeService { get; set; } + private List catalogItems = new List(); private List catalogTypes = new List(); private List catalogBrands = new List(); @@ -22,9 +30,9 @@ namespace BlazorAdmin.Pages.CatalogItemPage { if (firstRender) { - catalogItems = await CatalogItemListPaged.HandleAsync(50); - catalogTypes = await TypeList.HandleAsync(); - catalogBrands = await BrandList.HandleAsync(); + catalogItems = await CatalogItemService.List(); + catalogTypes = await CatalogTypeService.List(); + catalogBrands = await CatalogBrandService.List(); CallRequestRefresh(); } @@ -54,7 +62,7 @@ namespace BlazorAdmin.Pages.CatalogItemPage private async Task ReloadCatalogItems() { - catalogItems = await new BlazorAdmin.Services.CatalogItemServices.ListPaged(Auth).HandleAsync(50); + catalogItems = await CatalogItemService.List(); StateHasChanged(); } } diff --git a/src/BlazorAdmin/Program.cs b/src/BlazorAdmin/Program.cs index a2adbee..89080ad 100644 --- a/src/BlazorAdmin/Program.cs +++ b/src/BlazorAdmin/Program.cs @@ -1,11 +1,14 @@ +using BlazorAdmin.Services; +using Blazored.LocalStorage; +using BlazorShared; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using System; using System.Net.Http; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.Extensions.DependencyInjection; -using BlazorAdmin.Services; -using Blazored.LocalStorage; -using Microsoft.AspNetCore.Components.Authorization; namespace BlazorAdmin { @@ -16,18 +19,36 @@ namespace BlazorAdmin var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("admin"); - builder.Services.AddTransient(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + var baseUrlConfig = new BaseUrlConfiguration(); + builder.Configuration.Bind(BaseUrlConfiguration.CONFIG_NAME, baseUrlConfig); + builder.Services.AddScoped(sp => baseUrlConfig); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + builder.Services.AddScoped(sp => new HttpClient() { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + + builder.Services.AddScoped(); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddAuthorizationCore(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => (CustomAuthStateProvider)sp.GetRequiredService()); + builder.Services.AddScoped(); + builder.Services.AddScoped(sp => (CustomAuthStateProvider)sp.GetRequiredService()); builder.Services.AddBlazorServices(); - await builder.Build().RunAsync(); + builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); + + await ClearLocalStorageCache(builder.Services); + + builder.Build().RunAsync(); + } + + private static async Task ClearLocalStorageCache(IServiceCollection services) + { + var sp = services.BuildServiceProvider(); + var localStorageService = sp.GetRequiredService(); + + await localStorageService.RemoveItemAsync("brands"); } } } diff --git a/src/BlazorAdmin/Services/AuthService.cs b/src/BlazorAdmin/Services/AuthService.cs index f426dbe..5c0721a 100644 --- a/src/BlazorAdmin/Services/AuthService.cs +++ b/src/BlazorAdmin/Services/AuthService.cs @@ -1,11 +1,8 @@ using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Threading.Tasks; using BlazorAdmin.JavaScript; using Blazored.LocalStorage; using Microsoft.JSInterop; -using BlazorShared.Authorization; namespace BlazorAdmin.Services { @@ -14,143 +11,32 @@ namespace BlazorAdmin.Services private readonly HttpClient _httpClient; private readonly ILocalStorageService _localStorage; private readonly IJSRuntime _jSRuntime; - private static bool InDocker { get; set; } - - public string ApiUrl => Constants.GetApiUrl(InDocker); public bool IsLoggedIn { get; set; } - public string UserName { get; set; } - public AuthService(HttpClient httpClient, ILocalStorageService localStorage, IJSRuntime jSRuntime) + public AuthService(HttpClient httpClient, + ILocalStorageService localStorage, + IJSRuntime jSRuntime) { _httpClient = httpClient; _localStorage = localStorage; _jSRuntime = jSRuntime; } - public HttpClient GetHttpClient() - { - return _httpClient; - } - public async Task Logout() { - await DeleteLocalStorage(); await DeleteCookies(); - RemoveAuthorizationHeader(); - UserName = null; IsLoggedIn = false; await LogoutIdentityManager(); } - public async Task RefreshLoginInfo() - { - await SetLoginData(); - } - - public async Task RefreshLoginInfoFromCookie() - { - var token = await new Cookies(_jSRuntime).GetCookie("token"); - await SaveTokenInLocalStorage(token); - - var username = await new Cookies(_jSRuntime).GetCookie("username"); - await SaveUsernameInLocalStorage(username); - - var inDocker = await new Cookies(_jSRuntime).GetCookie("inDocker"); - await SaveInDockerInLocalStorage(inDocker); - - await RefreshLoginInfo(); - } - - public async Task GetToken() - { - - var token = await _localStorage.GetItemAsync("authToken"); - return token; - } - - public async Task GetTokenFromController() - { - return await _httpClient.GetFromJsonAsync("User"); - } - - public async Task GetUsername() - { - var username = await _localStorage.GetItemAsync("username"); - return username; - } - - public async Task GetInDocker() - { - return (await _localStorage.GetItemAsync("inDocker")).ToLower() == "true"; - } - private async Task LogoutIdentityManager() { await _httpClient.PostAsync("Identity/Account/Logout", null); } - private async Task DeleteLocalStorage() - { - await _localStorage.RemoveItemAsync("authToken"); - await _localStorage.RemoveItemAsync("username"); - await _localStorage.RemoveItemAsync("inDocker"); - } - private async Task DeleteCookies() { await new Cookies(_jSRuntime).DeleteCookie("token"); - await new Cookies(_jSRuntime).DeleteCookie("username"); - await new Cookies(_jSRuntime).DeleteCookie("inDocker"); } - - private async Task SetLoginData() - { - IsLoggedIn = !string.IsNullOrEmpty(await GetToken()); - UserName = await GetUsername(); - InDocker = await GetInDocker(); - await SetAuthorizationHeader(); - } - - private void RemoveAuthorizationHeader() - { - if (_httpClient.DefaultRequestHeaders.Contains("Authorization")) - { - _httpClient.DefaultRequestHeaders.Remove("Authorization"); - } - } - - private async Task SaveTokenInLocalStorage(string token) - { - if (string.IsNullOrEmpty(token)) - { - return; - } - await _localStorage.SetItemAsync("authToken", token); - } - - private async Task SaveUsernameInLocalStorage(string username) - { - if (string.IsNullOrEmpty(username)) - { - return; - } - await _localStorage.SetItemAsync("username", username); - } - - private async Task SaveInDockerInLocalStorage(string inDocker) - { - if (string.IsNullOrEmpty(inDocker)) - { - return; - } - await _localStorage.SetItemAsync("inDocker", inDocker); - } - - private async Task SetAuthorizationHeader() - { - var token = await GetToken(); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - } } diff --git a/src/BlazorAdmin/Services/CacheEntry.cs b/src/BlazorAdmin/Services/CacheEntry.cs new file mode 100644 index 0000000..97abd8e --- /dev/null +++ b/src/BlazorAdmin/Services/CacheEntry.cs @@ -0,0 +1,19 @@ +using System; + +namespace BlazorAdmin.Services +{ + public class CacheEntry + { + public CacheEntry(T item) + { + Value = item; + } + public CacheEntry() + { + + } + + public T Value { get; set; } + public DateTime DateCreated { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/BlazorAdmin/Services/CachedCatalogBrandServiceDecorator.cs b/src/BlazorAdmin/Services/CachedCatalogBrandServiceDecorator.cs new file mode 100644 index 0000000..b076490 --- /dev/null +++ b/src/BlazorAdmin/Services/CachedCatalogBrandServiceDecorator.cs @@ -0,0 +1,59 @@ +using Blazored.LocalStorage; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BlazorAdmin.Services +{ + public class CachedCatalogBrandServiceDecorator : ICatalogBrandService + { + // TODO: Make a generic decorator for any LookupData type + private readonly ILocalStorageService _localStorageService; + private readonly CatalogBrandService _catalogBrandService; + private ILogger _logger; + + public CachedCatalogBrandServiceDecorator(ILocalStorageService localStorageService, + CatalogBrandService catalogBrandService, + ILogger logger) + { + _localStorageService = localStorageService; + _catalogBrandService = catalogBrandService; + _logger = logger; + + } + + public async Task GetById(int id) + { + return (await List()).FirstOrDefault(x => x.Id == id); + } + + public async Task> List() + { + string key = "brands"; + var cacheEntry = await _localStorageService.GetItemAsync>>(key); + if (cacheEntry != null) + { + _logger.LogInformation("Loading brands from local storage."); + // TODO: Get Default Cache Duration from Config + if (cacheEntry.DateCreated.AddMinutes(1) > DateTime.UtcNow) + { + return cacheEntry.Value; + } + else + { + _logger.LogInformation("Cache expired; removing brands from local storage."); + await _localStorageService.RemoveItemAsync(key); + } + } + + var brands = await _catalogBrandService.List(); + var entry = new CacheEntry>(brands); + await _localStorageService.SetItemAsync(key, entry); + return brands; + } + } +} diff --git a/src/BlazorAdmin/Services/CachedCatalogItemServiceDecorator.cs b/src/BlazorAdmin/Services/CachedCatalogItemServiceDecorator.cs new file mode 100644 index 0000000..885cb94 --- /dev/null +++ b/src/BlazorAdmin/Services/CachedCatalogItemServiceDecorator.cs @@ -0,0 +1,114 @@ +using Blazored.LocalStorage; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BlazorAdmin.Services +{ + public class CachedCatalogItemServiceDecorator : ICatalogItemService + { + private readonly ILocalStorageService _localStorageService; + private readonly CatalogItemService _catalogItemService; + private ILogger _logger; + + public CachedCatalogItemServiceDecorator(ILocalStorageService localStorageService, + CatalogItemService catalogItemService, + ILogger logger) + { + _localStorageService = localStorageService; + _catalogItemService = catalogItemService; + _logger = logger; + } + + public async Task> ListPaged(int pageSize) + { + string key = "items"; + var cacheEntry = await _localStorageService.GetItemAsync>>(key); + if (cacheEntry != null) + { + _logger.LogInformation("Loading items from local storage."); + if (cacheEntry.DateCreated.AddMinutes(1) > DateTime.UtcNow) + { + return cacheEntry.Value; + } + else + { + _logger.LogInformation($"Loading {key} from local storage."); + await _localStorageService.RemoveItemAsync(key); + } + } + + var items = await _catalogItemService.ListPaged(pageSize); + var entry = new CacheEntry>(items); + await _localStorageService.SetItemAsync(key, entry); + return items; + } + + public async Task> List() + { + string key = "items"; + var cacheEntry = await _localStorageService.GetItemAsync>>(key); + if (cacheEntry != null) + { + _logger.LogInformation("Loading items from local storage."); + if (cacheEntry.DateCreated.AddMinutes(1) > DateTime.UtcNow) + { + return cacheEntry.Value; + } + else + { + _logger.LogInformation($"Loading {key} from local storage."); + await _localStorageService.RemoveItemAsync(key); + } + } + + var items = await _catalogItemService.List(); + var entry = new CacheEntry>(items); + await _localStorageService.SetItemAsync(key, entry); + return items; + } + + public async Task GetById(int id) + { + return (await List()).FirstOrDefault(x => x.Id == id); + } + + public async Task Create(CreateCatalogItemRequest catalogItem) + { + var result = await _catalogItemService.Create(catalogItem); + await RefreshLocalStorageList(); + + return result; + } + + public async Task Edit(CatalogItem catalogItem) + { + var result = await _catalogItemService.Edit(catalogItem); + await RefreshLocalStorageList(); + + return result; + } + + public async Task Delete(int id) + { + var result = await _catalogItemService.Delete(id); + await RefreshLocalStorageList(); + + return result; + } + + private async Task RefreshLocalStorageList() + { + string key = "items"; + + await _localStorageService.RemoveItemAsync(key); + var items = await _catalogItemService.List(); + var entry = new CacheEntry>(items); + await _localStorageService.SetItemAsync(key, entry); + } + } +} diff --git a/src/BlazorAdmin/Services/CachedCatalogTypeServiceDecorator.cs b/src/BlazorAdmin/Services/CachedCatalogTypeServiceDecorator.cs new file mode 100644 index 0000000..86c0cc0 --- /dev/null +++ b/src/BlazorAdmin/Services/CachedCatalogTypeServiceDecorator.cs @@ -0,0 +1,57 @@ +using Blazored.LocalStorage; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BlazorAdmin.Services +{ + public class CachedCatalogTypeServiceDecorator : ICatalogTypeService + { + // TODO: Make a generic decorator for any LookupData type + private readonly ILocalStorageService _localStorageService; + private readonly CatalogTypeService _catalogTypeService; + private ILogger _logger; + + public CachedCatalogTypeServiceDecorator(ILocalStorageService localStorageService, + CatalogTypeService catalogTypeService, + ILogger logger) + { + _localStorageService = localStorageService; + _catalogTypeService = catalogTypeService; + _logger = logger; + } + + public async Task GetById(int id) + { + return (await List()).FirstOrDefault(x => x.Id == id); + } + + public async Task> List() + { + string key = "types"; + var cacheEntry = await _localStorageService.GetItemAsync>>(key); + if (cacheEntry != null) + { + _logger.LogInformation("Loading types from local storage."); + if (cacheEntry.DateCreated.AddMinutes(1) > DateTime.UtcNow) + { + return cacheEntry.Value; + } + else + { + _logger.LogInformation("Cache expired; removing types from local storage."); + await _localStorageService.RemoveItemAsync(key); + } + } + + var types = await _catalogTypeService.List(); + var entry = new CacheEntry>(types); + await _localStorageService.SetItemAsync(key, entry); + return types; + } + } +} diff --git a/src/BlazorAdmin/Services/CatalogBrandService.cs b/src/BlazorAdmin/Services/CatalogBrandService.cs new file mode 100644 index 0000000..263d6ea --- /dev/null +++ b/src/BlazorAdmin/Services/CatalogBrandService.cs @@ -0,0 +1,41 @@ +using BlazorShared; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + + +namespace BlazorAdmin.Services +{ + public class CatalogBrandService : ICatalogBrandService + { + // TODO: Make a generic service for any LookupData type + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private string _apiUrl; + + public CatalogBrandService(HttpClient httpClient, + BaseUrlConfiguration baseUrlConfiguration, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _apiUrl = baseUrlConfiguration.ApiBase; + } + + public async Task GetById(int id) + { + return (await List()).FirstOrDefault(x => x.Id == id); + } + + public async Task> List() + { + _logger.LogInformation("Fetching brands from API."); + return (await _httpClient.GetFromJsonAsync($"{_apiUrl}catalog-brands"))?.CatalogBrands; + } + } +} diff --git a/src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrand.cs b/src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrand.cs deleted file mode 100644 index b8d8a5b..0000000 --- a/src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrand.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace BlazorAdmin.Services.CatalogBrandServices -{ - public class CatalogBrand - { - public int Id { get; set; } - public string Name { get; set; } - } -} diff --git a/src/BlazorAdmin/Services/CatalogBrandServices/List.cs b/src/BlazorAdmin/Services/CatalogBrandServices/List.cs deleted file mode 100644 index 454840a..0000000 --- a/src/BlazorAdmin/Services/CatalogBrandServices/List.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogBrandServices -{ - public class List - { - private readonly AuthService _authService; - private readonly HttpClient _httpClient; - - public List(AuthService authService, HttpClient httpClient) - { - _authService = authService; - _httpClient = httpClient; - } - - public async Task> HandleAsync() - { - return (await _httpClient.GetFromJsonAsync($"{_authService.ApiUrl}catalog-brands"))?.CatalogBrands; - } - - public static string GetBrandName(IEnumerable brands, int brandId) - { - var type = brands.FirstOrDefault(t => t.Id == brandId); - - return type == null ? "None" : type.Name; - } - - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemService.cs b/src/BlazorAdmin/Services/CatalogItemService.cs new file mode 100644 index 0000000..a33afae --- /dev/null +++ b/src/BlazorAdmin/Services/CatalogItemService.cs @@ -0,0 +1,102 @@ +using BlazorShared; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + + +namespace BlazorAdmin.Services +{ + public class CatalogItemService : ICatalogItemService + { + private readonly ICatalogBrandService _brandService; + private readonly ICatalogTypeService _typeService; + private readonly HttpService _httpService; + private readonly ILogger _logger; + private string _apiUrl; + + public CatalogItemService(ICatalogBrandService brandService, + ICatalogTypeService typeService, + HttpService httpService, + BaseUrlConfiguration baseUrlConfiguration, + ILogger logger) + { + _brandService = brandService; + _typeService = typeService; + + _httpService = httpService; + _logger = logger; + _apiUrl = baseUrlConfiguration.ApiBase; + } + + public async Task Create(CreateCatalogItemRequest catalogItem) + { + return (await _httpService.HttpPost("catalog-items", catalogItem)).CatalogItem; + } + + public async Task Edit(CatalogItem catalogItem) + { + return (await _httpService.HttpPut("catalog-items", catalogItem)).CatalogItem; + } + + public async Task Delete(int catalogItemId) + { + return (await _httpService.HttpDelete("catalog-items", catalogItemId)).Status; + } + + public async Task GetById(int id) + { + var brandListTask = _brandService.List(); + var typeListTask = _typeService.List(); + var itemGetTask = _httpService.HttpGet($"catalog-items/{id}"); + await Task.WhenAll(brandListTask, typeListTask, itemGetTask); + var brands = brandListTask.Result; + var types = typeListTask.Result; + var catalogItem = itemGetTask.Result.CatalogItem; + catalogItem.CatalogBrand = brands.FirstOrDefault(b => b.Id == catalogItem.CatalogBrandId)?.Name; + catalogItem.CatalogType = types.FirstOrDefault(t => t.Id == catalogItem.CatalogTypeId)?.Name; + return catalogItem; + } + + public async Task> ListPaged(int pageSize) + { + _logger.LogInformation("Fetching catalog items from API."); + + var brandListTask = _brandService.List(); + var typeListTask = _typeService.List(); + var itemListTask = _httpService.HttpGet($"catalog-items?PageSize=10"); + await Task.WhenAll(brandListTask, typeListTask, itemListTask); + var brands = brandListTask.Result; + var types = typeListTask.Result; + var items = itemListTask.Result.CatalogItems; + foreach (var item in items) + { + item.CatalogBrand = brands.FirstOrDefault(b => b.Id == item.CatalogBrandId)?.Name; + item.CatalogType = types.FirstOrDefault(t => t.Id == item.CatalogTypeId)?.Name; + } + return items; + } + + public async Task> List() + { + _logger.LogInformation("Fetching catalog items from API."); + + var brandListTask = _brandService.List(); + var typeListTask = _typeService.List(); + //TODO: Need to change the api to support full list + var itemListTask = _httpService.HttpGet($"catalog-items?PageSize=100"); + await Task.WhenAll(brandListTask, typeListTask, itemListTask); + var brands = brandListTask.Result; + var types = typeListTask.Result; + var items = itemListTask.Result.CatalogItems; + foreach (var item in items) + { + item.CatalogBrand = brands.FirstOrDefault(b => b.Id == item.CatalogBrandId)?.Name; + item.CatalogType = types.FirstOrDefault(t => t.Id == item.CatalogTypeId)?.Name; + } + return items; + } + } +} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemResult.cs b/src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemResult.cs deleted file mode 100644 index 37cf03f..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class CreateCatalogItemResult - { - public CatalogItem CatalogItem { get; set; } = new CatalogItem(); - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Create.cs b/src/BlazorAdmin/Services/CatalogItemServices/Create.cs deleted file mode 100644 index 74b9cc8..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/Create.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class Create - { - private readonly HttpService _httpService; - - public Create(AuthService authService) - { - _httpService = new HttpService(authService.GetHttpClient(), authService.ApiUrl); - } - - public async Task HandleAsync(CreateCatalogItemRequest catalogItem) - { - return (await _httpService.HttpPost("catalog-items", catalogItem)).CatalogItem; - } - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Delete.DeleteCatalogItemResult.cs b/src/BlazorAdmin/Services/CatalogItemServices/Delete.DeleteCatalogItemResult.cs deleted file mode 100644 index 8436815..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/Delete.DeleteCatalogItemResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class DeleteCatalogItemResult - { - public string Status { get; set; } = "Deleted"; - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Delete.cs b/src/BlazorAdmin/Services/CatalogItemServices/Delete.cs deleted file mode 100644 index 86f82b9..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/Delete.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class Delete - { - private readonly HttpService _httpService; - - public Delete(AuthService authService) - { - _httpService = new HttpService(authService.GetHttpClient(), authService.ApiUrl); - } - - public async Task HandleAsync(int catalogItemId) - { - return (await _httpService.HttpDelete("catalog-items", catalogItemId)).Status; - } - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Edit.cs b/src/BlazorAdmin/Services/CatalogItemServices/Edit.cs deleted file mode 100644 index a5fd6d3..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/Edit.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class Edit - { - private readonly HttpService _httpService; - - public Edit(AuthService authService) - { - _httpService = new HttpService(authService.GetHttpClient(), authService.ApiUrl); - } - - public async Task HandleAsync(CatalogItem catalogItem) - { - return (await _httpService.HttpPut("catalog-items", catalogItem)).CatalogItem; - } - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/GetById.GetByIdCatalogItemResult.cs b/src/BlazorAdmin/Services/CatalogItemServices/GetById.GetByIdCatalogItemResult.cs deleted file mode 100644 index e7e9f1d..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/GetById.GetByIdCatalogItemResult.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class GetByIdCatalogItemResult - { - public CatalogItem CatalogItem { get; set; } = new CatalogItem(); - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/GetById.cs b/src/BlazorAdmin/Services/CatalogItemServices/GetById.cs deleted file mode 100644 index 01347f3..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/GetById.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class GetById - { - private readonly HttpService _httpService; - - public GetById(AuthService authService) - { - _httpService = new HttpService(authService.GetHttpClient(), authService.ApiUrl); - } - - public async Task HandleAsync(int catalogItemId) - { - return (await _httpService.HttpGet($"catalog-items/{catalogItemId}")).CatalogItem; - } - } -} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/ListPaged.cs b/src/BlazorAdmin/Services/CatalogItemServices/ListPaged.cs deleted file mode 100644 index 85a1e10..0000000 --- a/src/BlazorAdmin/Services/CatalogItemServices/ListPaged.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogItemServices -{ - public class ListPaged - { - private readonly HttpService _httpService; - - public ListPaged(AuthService authService) - { - _httpService = new HttpService(authService.GetHttpClient(), authService.ApiUrl); - } - - public async Task> HandleAsync(int pageSize) - { - return (await _httpService.HttpGet($"catalog-items?PageSize={pageSize}")).CatalogItems; - } - - } -} \ No newline at end of file diff --git a/src/BlazorAdmin/Services/CatalogTypeService.cs b/src/BlazorAdmin/Services/CatalogTypeService.cs new file mode 100644 index 0000000..a41fc7d --- /dev/null +++ b/src/BlazorAdmin/Services/CatalogTypeService.cs @@ -0,0 +1,40 @@ +using BlazorShared; +using BlazorShared.Interfaces; +using BlazorShared.Models; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; + +namespace BlazorAdmin.Services +{ + public class CatalogTypeService : ICatalogTypeService + { + // TODO: Make a generic service for any LookupData type + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private string _apiUrl; + + public CatalogTypeService(HttpClient httpClient, + BaseUrlConfiguration baseUrlConfiguration, + ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _apiUrl = baseUrlConfiguration.ApiBase; + } + + public async Task GetById(int id) + { + return (await List()).FirstOrDefault(x => x.Id == id); + } + + public async Task> List() + { + _logger.LogInformation("Fetching types from API."); + return (await _httpClient.GetFromJsonAsync($"{_apiUrl}catalog-types"))?.CatalogTypes; + } + } +} diff --git a/src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogType.cs b/src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogType.cs deleted file mode 100644 index 0a22de3..0000000 --- a/src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace BlazorAdmin.Services.CatalogTypeServices -{ - public class CatalogType - { - public int Id { get; set; } - public string Name { get; set; } - } -} diff --git a/src/BlazorAdmin/Services/CatalogTypeServices/List.cs b/src/BlazorAdmin/Services/CatalogTypeServices/List.cs deleted file mode 100644 index 174fb80..0000000 --- a/src/BlazorAdmin/Services/CatalogTypeServices/List.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; - -namespace BlazorAdmin.Services.CatalogTypeServices -{ - public class List - { - private readonly AuthService _authService; - private readonly HttpClient _httpClient; - - public List(AuthService authService, HttpClient httpClient) - { - _authService = authService; - _httpClient = httpClient; - } - - public async Task> HandleAsync() - { - return (await _httpClient.GetFromJsonAsync($"{_authService.ApiUrl}catalog-types"))?.CatalogTypes; - } - - public static string GetTypeName(IEnumerable types, int typeId) - { - var type = types.FirstOrDefault(t => t.Id == typeId); - - return type == null ? "None" : type.Name; - } - - } -} diff --git a/src/BlazorAdmin/Services/HttpService.cs b/src/BlazorAdmin/Services/HttpService.cs index 7344864..489a7a4 100644 --- a/src/BlazorAdmin/Services/HttpService.cs +++ b/src/BlazorAdmin/Services/HttpService.cs @@ -1,4 +1,5 @@ -using System.Net.Http; +using BlazorShared; +using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -10,10 +11,11 @@ namespace BlazorAdmin.Services private readonly HttpClient _httpClient; private readonly string _apiUrl; - public HttpService(HttpClient httpClient, string apiUrl) + + public HttpService(HttpClient httpClient, BaseUrlConfiguration baseUrlConfiguration) { _httpClient = httpClient; - _apiUrl = apiUrl; + _apiUrl = baseUrlConfiguration.ApiBase; } public async Task HttpGet(string uri) diff --git a/src/BlazorAdmin/ServicesConfiguration.cs b/src/BlazorAdmin/ServicesConfiguration.cs index dc13468..fb4302b 100644 --- a/src/BlazorAdmin/ServicesConfiguration.cs +++ b/src/BlazorAdmin/ServicesConfiguration.cs @@ -1,22 +1,21 @@ -using BlazorAdmin.Services.CatalogItemServices; +using BlazorAdmin.Services; +using BlazorShared.Interfaces; using Microsoft.Extensions.DependencyInjection; namespace BlazorAdmin { public static class ServicesConfiguration { - public static IServiceCollection AddBlazorServices(this IServiceCollection service) + public static IServiceCollection AddBlazorServices(this IServiceCollection services) { - service.AddScoped(); - service.AddScoped(); - service.AddScoped(); - service.AddScoped(); - service.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - service.AddScoped(); - service.AddScoped(); - - return service; + return services; } } } diff --git a/src/BlazorAdmin/Shared/CustomInputSelect.cs b/src/BlazorAdmin/Shared/CustomInputSelect.cs new file mode 100644 index 0000000..238a79a --- /dev/null +++ b/src/BlazorAdmin/Shared/CustomInputSelect.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components.Forms; + +namespace BlazorAdmin.Shared +{ + /// + /// This is needed until 5.0 ships with native support + /// https://www.pragimtech.com/blog/blazor/inputselect-does-not-support-system.int32/ + /// + /// + public class CustomInputSelect : InputSelect + { + protected override bool TryParseValueFromString(string value, out TValue result, + out string validationErrorMessage) + { + if (typeof(TValue) == typeof(int)) + { + if (int.TryParse(value, out var resultInt)) + { + result = (TValue)(object)resultInt; + validationErrorMessage = null; + return true; + } + else + { + result = default; + validationErrorMessage = + $"The selected value {value} is not a valid number."; + return false; + } + } + else + { + return base.TryParseValueFromString(value, out result, + out validationErrorMessage); + } + } + } +} diff --git a/src/BlazorAdmin/Shared/MainLayout.razor b/src/BlazorAdmin/Shared/MainLayout.razor index c9438fa..7070aae 100644 --- a/src/BlazorAdmin/Shared/MainLayout.razor +++ b/src/BlazorAdmin/Shared/MainLayout.razor @@ -1,4 +1,4 @@ -@inject AuthService Auth +@inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JSRuntime @inherits BlazorAdmin.Helpers.BlazorLayoutComponent @@ -25,8 +25,9 @@ { if (firstRender) { - await Auth.RefreshLoginInfoFromCookie(); - if (!Auth.IsLoggedIn) + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + + if(authState.User == null) { await new Route(JSRuntime).RouteOutside("/Identity/Account/Login"); } @@ -35,5 +36,4 @@ await base.OnAfterRenderAsync(firstRender); } - } diff --git a/src/BlazorAdmin/Shared/NavMenu.razor b/src/BlazorAdmin/Shared/NavMenu.razor index 129eb57..49c44cd 100644 --- a/src/BlazorAdmin/Shared/NavMenu.razor +++ b/src/BlazorAdmin/Shared/NavMenu.razor @@ -1,5 +1,4 @@ -@inject AuthService Auth -@inherits BlazorAdmin.Helpers.BlazorComponent +@inherits BlazorAdmin.Helpers.BlazorComponent @code { private bool collapseNavMenu = true; - public string UserName { get; set; } private string NavMenuCssClass => collapseNavMenu ? "collapse" : null; diff --git a/src/BlazorAdmin/_Imports.razor b/src/BlazorAdmin/_Imports.razor index 3d938ec..c53df61 100644 --- a/src/BlazorAdmin/_Imports.razor +++ b/src/BlazorAdmin/_Imports.razor @@ -11,9 +11,8 @@ @using BlazorAdmin @using BlazorAdmin.Shared @using BlazorAdmin.Services -@using BlazorAdmin.Services.CatalogBrandServices -@using BlazorAdmin.Services.CatalogItemServices -@using BlazorAdmin.Services.CatalogTypeServices @using BlazorAdmin.JavaScript @using BlazorShared.Authorization +@using BlazorShared.Interfaces @using BlazorInputFile +@using BlazorShared.Models diff --git a/src/BlazorAdmin/wwwroot/appsettings.Docker.json b/src/BlazorAdmin/wwwroot/appsettings.Docker.json new file mode 100644 index 0000000..98445e3 --- /dev/null +++ b/src/BlazorAdmin/wwwroot/appsettings.Docker.json @@ -0,0 +1,6 @@ +{ + "baseUrls": { + "apiBase": "http://localhost:5200/api/", + "webBase": "http://host.docker.internal:5106/" + } +} \ No newline at end of file diff --git a/src/BlazorAdmin/wwwroot/appsettings.json b/src/BlazorAdmin/wwwroot/appsettings.json new file mode 100644 index 0000000..cefa244 --- /dev/null +++ b/src/BlazorAdmin/wwwroot/appsettings.json @@ -0,0 +1,14 @@ +{ + "baseUrls": { + "apiBase": "https://localhost:5099/api/", + "webBase": "https://localhost:44315/" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "System": "Warning" + } + } +} \ No newline at end of file diff --git a/src/BlazorAdmin/wwwroot/sample-data/weather.json b/src/BlazorAdmin/wwwroot/sample-data/weather.json deleted file mode 100644 index 06463c0..0000000 --- a/src/BlazorAdmin/wwwroot/sample-data/weather.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "date": "2018-05-06", - "temperatureC": 1, - "summary": "Freezing" - }, - { - "date": "2018-05-07", - "temperatureC": 14, - "summary": "Bracing" - }, - { - "date": "2018-05-08", - "temperatureC": -13, - "summary": "Freezing" - }, - { - "date": "2018-05-09", - "temperatureC": -16, - "summary": "Balmy" - }, - { - "date": "2018-05-10", - "temperatureC": -2, - "summary": "Chilly" - } -] diff --git a/src/BlazorShared/Authorization/Constants.cs b/src/BlazorShared/Authorization/Constants.cs index d787c83..4ddc121 100644 --- a/src/BlazorShared/Authorization/Constants.cs +++ b/src/BlazorShared/Authorization/Constants.cs @@ -6,23 +6,5 @@ { public const string ADMINISTRATORS = "Administrators"; } - public static string GetApiUrl(bool inDocker) => - inDocker ? DOCKER_API_URL : API_URL; - - public static string GetWebUrl(bool inDocker) => - inDocker ? DOCKER_WEB_URL : WEB_URL; - - public static string GetWebUrlInternal(bool inDocker) => - inDocker ? DOCKER_WEB_URL.Replace("localhost", "host.docker.internal") : WEB_URL; - - public static string GetOriginWebUrl(bool inDocker) => - GetWebUrl(inDocker).TrimEnd('/'); - - private const string API_URL = "https://localhost:5099/api/"; - private const string DOCKER_API_URL = "http://localhost:5200/api/"; - - private const string WEB_URL = "https://localhost:44315/"; - private const string DOCKER_WEB_URL = "http://localhost:5106/"; - } } diff --git a/src/BlazorShared/Authorization/UserInfo.cs b/src/BlazorShared/Authorization/UserInfo.cs index 19cbd11..9ae24b8 100644 --- a/src/BlazorShared/Authorization/UserInfo.cs +++ b/src/BlazorShared/Authorization/UserInfo.cs @@ -8,6 +8,7 @@ namespace BlazorShared.Authorization public bool IsAuthenticated { get; set; } public string NameClaimType { get; set; } public string RoleClaimType { get; set; } + public string Token { get; set; } public IEnumerable Claims { get; set; } } } diff --git a/src/BlazorShared/BaseUrlConfiguration.cs b/src/BlazorShared/BaseUrlConfiguration.cs new file mode 100644 index 0000000..419735f --- /dev/null +++ b/src/BlazorShared/BaseUrlConfiguration.cs @@ -0,0 +1,10 @@ +namespace BlazorShared +{ + public class BaseUrlConfiguration + { + public const string CONFIG_NAME = "baseUrls"; + + public string ApiBase { get; set; } + public string WebBase { get; set; } + } +} diff --git a/src/BlazorShared/BlazorShared.csproj b/src/BlazorShared/BlazorShared.csproj index 07b614b..0e231e9 100644 --- a/src/BlazorShared/BlazorShared.csproj +++ b/src/BlazorShared/BlazorShared.csproj @@ -6,4 +6,8 @@ BlazorShared + + + + diff --git a/src/BlazorShared/Interfaces/ICatalogBrandService.cs b/src/BlazorShared/Interfaces/ICatalogBrandService.cs new file mode 100644 index 0000000..ba2f920 --- /dev/null +++ b/src/BlazorShared/Interfaces/ICatalogBrandService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BlazorShared.Models; + +namespace BlazorShared.Interfaces +{ + public interface ICatalogBrandService + { + Task> List(); + Task GetById(int id); + } +} diff --git a/src/BlazorShared/Interfaces/ICatalogItemService.cs b/src/BlazorShared/Interfaces/ICatalogItemService.cs new file mode 100644 index 0000000..3b277d3 --- /dev/null +++ b/src/BlazorShared/Interfaces/ICatalogItemService.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BlazorShared.Models; + +namespace BlazorShared.Interfaces +{ + public interface ICatalogItemService + { + Task Create(CreateCatalogItemRequest catalogItem); + Task Edit(CatalogItem catalogItem); + Task Delete(int id); + Task GetById(int id); + Task> ListPaged(int pageSize); + Task> List(); + } +} diff --git a/src/BlazorShared/Interfaces/ICatalogTypeService.cs b/src/BlazorShared/Interfaces/ICatalogTypeService.cs new file mode 100644 index 0000000..437ff34 --- /dev/null +++ b/src/BlazorShared/Interfaces/ICatalogTypeService.cs @@ -0,0 +1,12 @@ +using BlazorShared.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace BlazorShared.Interfaces +{ + public interface ICatalogTypeService + { + Task> List(); + Task GetById(int id); + } +} diff --git a/src/BlazorShared/Models/CatalogBrand.cs b/src/BlazorShared/Models/CatalogBrand.cs new file mode 100644 index 0000000..631a23b --- /dev/null +++ b/src/BlazorShared/Models/CatalogBrand.cs @@ -0,0 +1,6 @@ +namespace BlazorShared.Models +{ + public class CatalogBrand : LookupData + { + } +} diff --git a/src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrandResult.cs b/src/BlazorShared/Models/CatalogBrandResponse.cs similarity index 62% rename from src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrandResult.cs rename to src/BlazorShared/Models/CatalogBrandResponse.cs index 88493ea..ba01032 100644 --- a/src/BlazorAdmin/Services/CatalogBrandServices/List.CatalogBrandResult.cs +++ b/src/BlazorShared/Models/CatalogBrandResponse.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; -namespace BlazorAdmin.Services.CatalogBrandServices +namespace BlazorShared.Models { - public class CatalogBrandResult + public class CatalogBrandResponse { public List CatalogBrands { get; set; } = new List(); } diff --git a/src/BlazorAdmin/Services/CatalogItemServices/CatalogItem.cs b/src/BlazorShared/Models/CatalogItem.cs similarity index 79% rename from src/BlazorAdmin/Services/CatalogItemServices/CatalogItem.cs rename to src/BlazorShared/Models/CatalogItem.cs index a732225..ddd0d00 100644 --- a/src/BlazorAdmin/Services/CatalogItemServices/CatalogItem.cs +++ b/src/BlazorShared/Models/CatalogItem.cs @@ -4,15 +4,17 @@ using System.IO; using System.Threading.Tasks; using BlazorInputFile; -namespace BlazorAdmin.Services.CatalogItemServices +namespace BlazorShared.Models { public class CatalogItem { public int Id { get; set; } public int CatalogTypeId { get; set; } + public string CatalogType { get; set; } = "NotSet"; public int CatalogBrandId { get; set; } + public string CatalogBrand { get; set; } = "NotSet"; [Required(ErrorMessage = "The Name field is required")] public string Name { get; set; } @@ -60,14 +62,17 @@ namespace BlazorAdmin.Services.CatalogItemServices public static async Task DataToBase64(IFileListEntry fileItem) { - using var reader = new StreamReader(fileItem.Data); + using ( var reader = new StreamReader(fileItem.Data)) + { + using (var memStream = new MemoryStream()) + { + await reader.BaseStream.CopyToAsync(memStream); + var fileData = memStream.ToArray(); + var encodedBase64 = Convert.ToBase64String(fileData); - await using var memStream = new MemoryStream(); - await reader.BaseStream.CopyToAsync(memStream); - var fileData = memStream.ToArray(); - var encodedBase64 = Convert.ToBase64String(fileData); - - return encodedBase64; + return encodedBase64; + } + } } private static bool IsExtensionValid(string fileName) diff --git a/src/BlazorShared/Models/CatalogType.cs b/src/BlazorShared/Models/CatalogType.cs new file mode 100644 index 0000000..ed7dad9 --- /dev/null +++ b/src/BlazorShared/Models/CatalogType.cs @@ -0,0 +1,6 @@ +namespace BlazorShared.Models +{ + public class CatalogType : LookupData + { + } +} diff --git a/src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogTypeResult.cs b/src/BlazorShared/Models/CatalogTypeResponse.cs similarity index 62% rename from src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogTypeResult.cs rename to src/BlazorShared/Models/CatalogTypeResponse.cs index a897967..664732e 100644 --- a/src/BlazorAdmin/Services/CatalogTypeServices/List.CatalogTypeResult.cs +++ b/src/BlazorShared/Models/CatalogTypeResponse.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; -namespace BlazorAdmin.Services.CatalogTypeServices +namespace BlazorShared.Models { - public class CatalogTypeResult + public class CatalogTypeResponse { public List CatalogTypes { get; set; } = new List(); } diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemRequest.cs b/src/BlazorShared/Models/CreateCatalogItemRequest.cs similarity index 94% rename from src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemRequest.cs rename to src/BlazorShared/Models/CreateCatalogItemRequest.cs index 1c4a343..0894109 100644 --- a/src/BlazorAdmin/Services/CatalogItemServices/Create.CreateCatalogItemRequest.cs +++ b/src/BlazorShared/Models/CreateCatalogItemRequest.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace BlazorAdmin.Services.CatalogItemServices +namespace BlazorShared.Models { public class CreateCatalogItemRequest { diff --git a/src/BlazorShared/Models/CreateCatalogItemResponse.cs b/src/BlazorShared/Models/CreateCatalogItemResponse.cs new file mode 100644 index 0000000..e51d3ff --- /dev/null +++ b/src/BlazorShared/Models/CreateCatalogItemResponse.cs @@ -0,0 +1,7 @@ +namespace BlazorShared.Models +{ + public class CreateCatalogItemResponse + { + public CatalogItem CatalogItem { get; set; } = new CatalogItem(); + } +} diff --git a/src/BlazorShared/Models/DeleteCatalogItemResponse.cs b/src/BlazorShared/Models/DeleteCatalogItemResponse.cs new file mode 100644 index 0000000..3cd1e22 --- /dev/null +++ b/src/BlazorShared/Models/DeleteCatalogItemResponse.cs @@ -0,0 +1,7 @@ +namespace BlazorShared.Models +{ + public class DeleteCatalogItemResponse + { + public string Status { get; set; } = "Deleted"; + } +} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/Edit.EditCatalogItemResult.cs b/src/BlazorShared/Models/EditCatalogItemResponse.cs similarity index 70% rename from src/BlazorAdmin/Services/CatalogItemServices/Edit.EditCatalogItemResult.cs rename to src/BlazorShared/Models/EditCatalogItemResponse.cs index 9f303eb..ed690d6 100644 --- a/src/BlazorAdmin/Services/CatalogItemServices/Edit.EditCatalogItemResult.cs +++ b/src/BlazorShared/Models/EditCatalogItemResponse.cs @@ -1,4 +1,4 @@ -namespace BlazorAdmin.Services.CatalogItemServices +namespace BlazorShared.Models { public class EditCatalogItemResult { diff --git a/src/BlazorShared/Models/LookupData.cs b/src/BlazorShared/Models/LookupData.cs new file mode 100644 index 0000000..53e029b --- /dev/null +++ b/src/BlazorShared/Models/LookupData.cs @@ -0,0 +1,8 @@ +namespace BlazorShared.Models +{ + public abstract class LookupData +{ + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/src/BlazorAdmin/Services/CatalogItemServices/ListPaged.PagedCatalogItemResult.cs b/src/BlazorShared/Models/PagedCatalogItemResponse.cs similarity index 67% rename from src/BlazorAdmin/Services/CatalogItemServices/ListPaged.PagedCatalogItemResult.cs rename to src/BlazorShared/Models/PagedCatalogItemResponse.cs index 531e78a..3395b1c 100644 --- a/src/BlazorAdmin/Services/CatalogItemServices/ListPaged.PagedCatalogItemResult.cs +++ b/src/BlazorShared/Models/PagedCatalogItemResponse.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; -namespace BlazorAdmin.Services.CatalogItemServices +namespace BlazorShared.Models { - public class PagedCatalogItemResult + public class PagedCatalogItemResponse { public List CatalogItems { get; set; } = new List(); public int PageCount { get; set; } = 0; diff --git a/src/PublicApi/CatalogItemEndpoints/Create.cs b/src/PublicApi/CatalogItemEndpoints/Create.cs index 68a6b2c..4b946b8 100644 --- a/src/PublicApi/CatalogItemEndpoints/Create.cs +++ b/src/PublicApi/CatalogItemEndpoints/Create.cs @@ -1,10 +1,8 @@ -using System; -using System.IO; +using System.IO; using Ardalis.ApiEndpoints; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.eShopWeb.ApplicationCore.Constants; using Microsoft.eShopWeb.ApplicationCore.Entities; using Microsoft.eShopWeb.ApplicationCore.Interfaces; using Swashbuckle.AspNetCore.Annotations; diff --git a/src/PublicApi/Program.cs b/src/PublicApi/Program.cs index 9d9b469..86d7537 100644 --- a/src/PublicApi/Program.cs +++ b/src/PublicApi/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; namespace Microsoft.eShopWeb.PublicApi { @@ -43,6 +44,13 @@ namespace Microsoft.eShopWeb.PublicApi public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((builderContext, config) => + { + var env = builderContext.HostingEnvironment; + config + .AddJsonFile("appsettings.json") + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true); + }) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/src/PublicApi/Properties/launchSettings.json b/src/PublicApi/Properties/launchSettings.json index 098f415..fbed524 100644 --- a/src/PublicApi/Properties/launchSettings.json +++ b/src/PublicApi/Properties/launchSettings.json @@ -25,13 +25,6 @@ "ASPNETCORE_ENVIRONMENT": "Development" }, "applicationUrl": "https://localhost:5099;http://localhost:5098" - }, - "Docker": { - "commandName": "Docker", - "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", - "publishAllPorts": true, - "useSSL": true } } } \ No newline at end of file diff --git a/src/PublicApi/Startup.cs b/src/PublicApi/Startup.cs index f0d0a20..ae00094 100644 --- a/src/PublicApi/Startup.cs +++ b/src/PublicApi/Startup.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; using AutoMapper; +using BlazorShared; using BlazorShared.Authorization; using MediatR; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -28,7 +29,6 @@ namespace Microsoft.eShopWeb.PublicApi public class Startup { private const string CORS_POLICY = "CorsPolicy"; - public static bool InDocker => Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; public Startup(IConfiguration configuration) { @@ -46,6 +46,11 @@ namespace Microsoft.eShopWeb.PublicApi //ConfigureProductionServices(services); } + public void ConfigureDockerServices(IServiceCollection services) + { + ConfigureDevelopmentServices(services); + } + private void ConfigureInMemoryDatabases(IServiceCollection services) { services.AddDbContext(c => @@ -90,7 +95,10 @@ namespace Microsoft.eShopWeb.PublicApi services.AddSingleton(new UriComposer(Configuration.Get())); services.AddScoped(typeof(IAppLogger<>), typeof(LoggerAdapter<>)); services.AddScoped(); - services.AddScoped(x => new WebFileSystem($"{Constants.GetWebUrlInternal(Startup.InDocker)}File")); + + var baseUrlConfig = new BaseUrlConfiguration(); + Configuration.Bind(BaseUrlConfiguration.CONFIG_NAME, baseUrlConfig); + services.AddScoped(x => new WebFileSystem($"{baseUrlConfig.WebBase}File")); services.AddMemoryCache(); @@ -112,15 +120,12 @@ namespace Microsoft.eShopWeb.PublicApi }; }); - services.AddCors(options => { options.AddPolicy(name: CORS_POLICY, builder => { - builder.WithOrigins("http://localhost:44319", - "https://localhost:44319", - Constants.GetOriginWebUrl(InDocker)); + builder.WithOrigins(baseUrlConfig.WebBase.Replace("host.docker.internal", "localhost").TrimEnd('/')); builder.AllowAnyMethod(); builder.AllowAnyHeader(); }); diff --git a/src/PublicApi/appsettings.Development.json b/src/PublicApi/appsettings.Development.json index 8983e0f..4af1b00 100644 --- a/src/PublicApi/appsettings.Development.json +++ b/src/PublicApi/appsettings.Development.json @@ -1,4 +1,8 @@ { + "baseUrls": { + "apiBase": "https://localhost:5099/api/", + "webBase": "https://localhost:44315/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/PublicApi/appsettings.Docker.json b/src/PublicApi/appsettings.Docker.json new file mode 100644 index 0000000..f3bcd06 --- /dev/null +++ b/src/PublicApi/appsettings.Docker.json @@ -0,0 +1,17 @@ +{ + "ConnectionStrings": { + "CatalogConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.CatalogDb;", + "IdentityConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.Identity;" + }, + "baseUrls": { + "apiBase": "http://localhost:5200/api/", + "webBase": "http://host.docker.internal:5106/" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/PublicApi/appsettings.json b/src/PublicApi/appsettings.json index fab9773..13b6949 100644 --- a/src/PublicApi/appsettings.json +++ b/src/PublicApi/appsettings.json @@ -1,4 +1,8 @@ { + "baseUrls": { + "apiBase": "https://localhost:5099/api/", + "webBase": "https://localhost:44315/" + }, "ConnectionStrings": { "CatalogConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.CatalogDb;", "IdentityConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.Identity;" diff --git a/src/Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/Web/Areas/Identity/Pages/Account/Login.cshtml.cs index 4c775f6..820136e 100644 --- a/src/Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/src/Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -22,16 +22,12 @@ namespace Microsoft.eShopWeb.Web.Areas.Identity.Pages.Account private readonly SignInManager _signInManager; private readonly ILogger _logger; private readonly IBasketService _basketService; - private readonly AuthService _authService; - private readonly ITokenClaimsService _tokenClaimsService; - public LoginModel(SignInManager signInManager, ILogger logger, IBasketService basketService, AuthService authService, ITokenClaimsService tokenClaimsService) + public LoginModel(SignInManager signInManager, ILogger logger, IBasketService basketService) { _signInManager = signInManager; _logger = logger; _basketService = basketService; - _authService = authService; - _tokenClaimsService = tokenClaimsService; } [BindProperty] @@ -88,8 +84,6 @@ namespace Microsoft.eShopWeb.Web.Areas.Identity.Pages.Account if (result.Succeeded) { - var token = await _tokenClaimsService.GetTokenAsync(Input.Email); - CreateAuthCookie(Input.Email, token, Startup.InDocker); _logger.LogInformation("User logged in."); await TransferAnonymousBasketToUserAsync(Input.Email); return LocalRedirect(returnUrl); @@ -114,15 +108,6 @@ namespace Microsoft.eShopWeb.Web.Areas.Identity.Pages.Account return Page(); } - private void CreateAuthCookie(string username, string token, bool inDocker) - { - var cookieOptions = new CookieOptions(); - cookieOptions.Expires = DateTime.Today.AddYears(10); - Response.Cookies.Append("token", token, cookieOptions); - Response.Cookies.Append("username", username, cookieOptions); - Response.Cookies.Append("inDocker", inDocker.ToString(), cookieOptions); - } - private async Task TransferAnonymousBasketToUserAsync(string userName) { if (Request.Cookies.ContainsKey(Constants.BASKET_COOKIENAME)) diff --git a/src/Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml b/src/Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml index bacc0ae..ea2a0df 100644 --- a/src/Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml +++ b/src/Web/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml @@ -1,8 +1,8 @@ - + - + diff --git a/src/Web/Views/Shared/_ValidationScriptsPartial.cshtml b/src/Web/Views/Shared/_ValidationScriptsPartial.cshtml index 27e0ea7..df42c5e 100644 --- a/src/Web/Views/Shared/_ValidationScriptsPartial.cshtml +++ b/src/Web/Views/Shared/_ValidationScriptsPartial.cshtml @@ -1,4 +1,4 @@ - + diff --git a/src/Web/appsettings.Development.json b/src/Web/appsettings.Development.json index e203e94..bd6e47b 100644 --- a/src/Web/appsettings.Development.json +++ b/src/Web/appsettings.Development.json @@ -1,4 +1,8 @@ { + "baseUrls": { + "apiBase": "https://localhost:5099/api/", + "webBase": "https://localhost:44315/" + }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/src/Web/appsettings.Docker.json b/src/Web/appsettings.Docker.json new file mode 100644 index 0000000..0b4b57a --- /dev/null +++ b/src/Web/appsettings.Docker.json @@ -0,0 +1,17 @@ +{ + "ConnectionStrings": { + "CatalogConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.CatalogDb;", + "IdentityConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.Identity;" + }, + "baseUrls": { + "apiBase": "http://localhost:5200/api/", + "webBase": "http://host.docker.internal:5106/" + }, + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information" + } + } +} diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index dd8d5ff..c2bc659 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -1,4 +1,8 @@ { + "baseUrls": { + "apiBase": "https://localhost:5099/api/", + "webBase": "https://localhost:44315/" + }, "ConnectionStrings": { "CatalogConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.CatalogDb;", "IdentityConnection": "Server=(localdb)\\mssqllocaldb;Integrated Security=true;Initial Catalog=Microsoft.eShopOnWeb.Identity;" diff --git a/src/Web/wwwroot/css/site.min.css b/src/Web/wwwroot/css/site.min.css index dd35a47..50f91d7 100644 --- a/src/Web/wwwroot/css/site.min.css +++ b/src/Web/wwwroot/css/site.min.css @@ -1 +1 @@ -@font-face{font-family:Montserrat;font-weight:400;src:url(".../fonts/Montserrat-Regular.eot?") format("eot"),url("../fonts/Montserrat-Regular.woff") format("woff"),url("../fonts/Montserrat-Regular.ttf") format("truetype"),url("../fonts/Montserrat-Regular.svg#Montserrat") format("svg")}@font-face{font-family:Montserrat;font-weight:700;src:url("../fonts/Montserrat-Bold.eot?") format("eot"),url("../fonts/Montserrat-Bold.woff") format("woff"),url("../fonts/Montserrat-Bold.ttf") format("truetype"),url("../fonts/Montserrat-Bold.svg#Montserrat") format("svg")}html,body{font-family:Montserrat,sans-serif;font-size:16px;font-weight:400;z-index:10}*,*::after,*::before{box-sizing:border-box}.preloading{color:#00a69c;display:block;font-size:1.5rem;left:50%;position:fixed;top:50%;transform:translate(-50%,-50%)}select::-ms-expand{display:none}@media screen and (min-width:992px){.form-input{max-width:360px;width:360px}}.form-input{border-radius:0;height:45px;padding:10px}.form-input-small{max-width:100px !important}.form-input-medium{width:150px !important}.alert{padding-left:0}.alert-danger{background-color:transparent;border:0;color:#fb0d0d;font-size:12px}a,a:active,a:hover,a:visited{color:#000;text-decoration:none;transition:color .35s}a:hover,a:active{color:#75b918;transition:color .35s}.esh-app-footer{background-color:#000;border-top:1px solid #eee;margin-top:2.5rem;padding-bottom:2.5rem;padding-top:2.5rem;width:100%;bottom:0}.esh-app-footer-brand{height:50px;width:230px}.esh-app-header{margin:15px}.esh-app-wrapper{display:flex;min-height:100vh;flex-direction:column;justify-content:space-between}.esh-header{background-color:#00a69c;height:4rem}.esh-header-back{color:rgba(255,255,255,.5);line-height:4rem;text-decoration:none;text-transform:uppercase;transition:color .35s}.esh-header-back:hover{color:#fff;transition:color .35s}.esh-identity{line-height:3rem;position:relative;text-align:right}.esh-identity-section{display:inline-block;width:100%}.esh-identity-name{display:inline-block}.esh-identity-name--upper{text-transform:uppercase}@media screen and (max-width:768px){.esh-identity-name{font-size:.85rem}}.esh-identity-image{display:inline-block}.esh-identity-drop{background:#fff;height:10px;width:10rem;overflow:hidden;padding:.5rem;position:absolute;right:0;top:2.5rem;transition:height .35s}.esh-identity:hover .esh-identity-drop{border:1px solid #eee;height:10rem;transition:height .35s}.esh-identity-item{cursor:pointer;transition:color .35s}.esh-identity-item:hover{color:#75b918;transition:color .35s}.esh-pager-wrapper{padding-top:1rem;text-align:center}.esh-pager-item{margin:0 5vw}.esh-pager-item.is-disabled{opacity:.5;pointer-events:none}.esh-pager-item--navigable{cursor:pointer;display:inline-block}.esh-pager-item--navigable:hover{color:#83d01b}@media screen and (max-width:1280px){.esh-pager-item{font-size:.85rem}}@media screen and (max-width:1024px){.esh-pager-item{margin:0 2.5vw}}.esh-basket{min-height:80vh}.esh-basket-titles{padding-bottom:1rem;padding-top:2rem}.esh-basket-titles--clean{padding-bottom:0;padding-top:0}.esh-basket-title{text-transform:uppercase}.esh-basket-items--border{border-bottom:1px solid #eee;padding:.5rem 0}.esh-basket-items--border:last-of-type{border-color:transparent}.esh-basket-items-margin-left1{margin-left:1px}.esh-basket-item{font-size:1rem;font-weight:300}.esh-basket-item--middle{line-height:8rem}@media screen and (max-width:1024px){.esh-basket-item--middle{line-height:1rem}}.esh-basket-item--mark{color:#00a69c}.esh-basket-image{height:8rem}.esh-basket-input{line-height:1rem;width:100%}.esh-basket-checkout{background-color:#83d01b;border:0;border-radius:0;color:#fff;display:inline-block;font-size:1rem;font-weight:400;margin-top:1rem;padding:1rem 1.5rem;text-align:center;text-transform:uppercase;transition:all .35s}.esh-basket-checkout:hover{background-color:#4a760f;transition:all .35s}.esh-basketstatus{cursor:pointer;display:inline-block;float:right;position:relative;transition:all .35s}.esh-basketstatus.is-disabled{opacity:.5;pointer-events:none}.esh-basketstatus-image{height:36px;margin-top:.5rem}.esh-basketstatus-badge{background-color:#83d01b;border-radius:50%;color:#fff;display:block;height:1.5rem;left:50%;position:absolute;text-align:center;top:0;transform:translateX(-38%);transition:all .35s;width:1.5rem}.esh-basketstatus-badge-inoperative{background-color:#f00;border-radius:50%;color:#fff;display:block;height:1.5rem;left:50%;position:absolute;text-align:center;top:0;transform:translateX(-38%);transition:all .35s;width:1.5rem}.esh-basketstatus:hover .esh-basketstatus-badge{background-color:transparent;color:#75b918;transition:all .35s}.esh-catalog-hero{background-image:url("../images/main_banner.png");background-size:cover;height:260px;width:100%}.esh-catalog-title{position:relative;top:74.28571px}.esh-catalog-filters{background-color:#00a69c;height:65px}.esh-catalog-filter{-webkit-appearance:none;background-color:transparent;border-color:#00d9cc;color:#fff;cursor:pointer;margin-right:1rem;margin-top:.5rem;min-width:140px;outline-color:#83d01b;padding-bottom:0;padding-left:.5rem;padding-right:.5rem;padding-top:1.5rem}.esh-catalog-filter option{background-color:#00a69c}.esh-catalog-label{display:inline-block;position:relative;z-index:0}.esh-catalog-label::before{color:rgba(255,255,255,.5);content:attr(data-title);font-size:.65rem;margin-left:.5rem;margin-top:.65rem;position:absolute;text-transform:uppercase;z-index:1}.esh-catalog-label::after{background-image:url("../images/arrow-down.png");content:'';height:7px;position:absolute;right:1.5rem;top:2.5rem;width:10px;z-index:1}.esh-catalog-send{background-color:#83d01b;color:#fff;cursor:pointer;font-size:1rem;margin-top:-1.5rem;transition:all .35s}.esh-catalog-send:hover{background-color:#4a760f;transition:all .35s}.esh-catalog-items{margin-top:1rem}.esh-catalog-item{margin-bottom:1.5rem;text-align:center;width:33%;display:inline-block;float:none !important}@media screen and (max-width:1024px){.esh-catalog-item{width:50%}}@media screen and (max-width:768px){.esh-catalog-item{width:100%}}.esh-catalog-thumbnail{max-width:370px;width:100%}.esh-catalog-button{background-color:#83d01b;border:0;color:#fff;cursor:pointer;font-size:1rem;height:3rem;margin-top:1rem;transition:all .35s;width:80%}.esh-catalog-button.is-disabled{opacity:.5;pointer-events:none}.esh-catalog-button:hover{background-color:#4a760f;transition:all .35s}.esh-catalog-name{font-size:1rem;font-weight:300;margin-top:.5rem;text-align:center;text-transform:uppercase}.esh-catalog-price{font-size:28px;font-weight:900;text-align:center}.esh-catalog-price::before{content:'$'}.esh-orders{min-height:80vh;overflow-x:hidden}.esh-orders-header{background-color:#00a69c;height:4rem}.esh-orders-back{color:rgba(255,255,255,.4);line-height:4rem;text-decoration:none;text-transform:uppercase;transition:color .35s}.esh-orders-back:hover{color:#fff;transition:color .35s}.esh-orders-titles{padding-bottom:1rem;padding-top:2rem}.esh-orders-title{text-transform:uppercase}.esh-orders-items{height:2rem;line-height:2rem;position:relative}.esh-orders-items:nth-of-type(2n+1):before{background-color:#eef;content:'';height:100%;left:0;margin-left:-100vw;position:absolute;top:0;width:200vw;z-index:-1}.esh-orders-item{font-weight:300}.esh-orders-item--hover{opacity:0;pointer-events:none}.esh-orders-items:hover .esh-orders-item--hover{opacity:1;pointer-events:all}.esh-orders-link{color:#83d01b;text-decoration:none;transition:color .35s}.esh-orders-link:hover{color:#75b918;transition:color .35s} \ No newline at end of file +@font-face{font-family:Montserrat;font-weight:400;src:url(".../fonts/Montserrat-Regular.eot?") format("eot"),url("../fonts/Montserrat-Regular.woff") format("woff"),url("../fonts/Montserrat-Regular.ttf") format("truetype"),url("../fonts/Montserrat-Regular.svg#Montserrat") format("svg")}@font-face{font-family:Montserrat;font-weight:700;src:url("../fonts/Montserrat-Bold.eot?") format("eot"),url("../fonts/Montserrat-Bold.woff") format("woff"),url("../fonts/Montserrat-Bold.ttf") format("truetype"),url("../fonts/Montserrat-Bold.svg#Montserrat") format("svg")}html,body{font-family:Montserrat,sans-serif;font-size:16px;font-weight:400;z-index:10}*,*::after,*::before{box-sizing:border-box}.preloading{color:#00a69c;display:block;font-size:1.5rem;left:50%;position:fixed;top:50%;transform:translate(-50%,-50%)}select::-ms-expand{display:none}@media screen and (min-width:992px){.form-input{max-width:360px;width:360px}}.form-input{border-radius:0;height:45px;padding:10px}.form-input-small{max-width:100px !important}.form-input-medium{width:150px !important}.alert{padding-left:0}.alert-danger{background-color:transparent;border:0;color:#fb0d0d;font-size:12px}a,a:active,a:hover,a:visited{color:#000;text-decoration:none;transition:color .35s}a:hover,a:active{color:#75b918;transition:color .35s}.esh-app-footer{background-color:#000;border-top:1px solid #eee;margin-top:2.5rem;padding-bottom:2.5rem;padding-top:2.5rem;width:100%;bottom:0}.esh-app-footer-brand{height:50px;width:230px}.esh-app-header{margin:15px}.esh-app-wrapper{display:flex;min-height:100vh;flex-direction:column;justify-content:space-between}.esh-header{background-color:#00a69c;height:4rem}.esh-header-back{color:rgba(255,255,255,.5);line-height:4rem;text-decoration:none;text-transform:uppercase;transition:color .35s}.esh-header-back:hover{color:#fff;transition:color .35s}.esh-identity{line-height:3rem;position:relative;text-align:right}.esh-identity-section{display:inline-block;width:100%}.esh-identity-name{display:inline-block}.esh-identity-name--upper{text-transform:uppercase}@media screen and (max-width:768px){.esh-identity-name{font-size:.85rem}}.esh-identity-image{display:inline-block}.esh-identity-drop{background:#fff;height:10px;width:10rem;overflow:hidden;padding:.5rem;position:absolute;right:0;top:2.5rem;transition:height .35s}.esh-identity:hover .esh-identity-drop{border:1px solid #eee;height:14rem;transition:height .35s;z-index:10}.esh-identity-item{cursor:pointer;transition:color .35s}.esh-identity-item:hover{color:#75b918;transition:color .35s}.esh-pager-wrapper{padding-top:1rem;text-align:center}.esh-pager-item{margin:0 5vw}.esh-pager-item.is-disabled{opacity:.5;pointer-events:none}.esh-pager-item--navigable{cursor:pointer;display:inline-block}.esh-pager-item--navigable:hover{color:#83d01b}@media screen and (max-width:1280px){.esh-pager-item{font-size:.85rem}}@media screen and (max-width:1024px){.esh-pager-item{margin:0 2.5vw}}.esh-basket{min-height:80vh}.esh-basket-titles{padding-bottom:1rem;padding-top:2rem}.esh-basket-titles--clean{padding-bottom:0;padding-top:0}.esh-basket-title{text-transform:uppercase}.esh-basket-items--border{border-bottom:1px solid #eee;padding:.5rem 0}.esh-basket-items--border:last-of-type{border-color:transparent}.esh-basket-items-margin-left1{margin-left:1px}.esh-basket-item{font-size:1rem;font-weight:300}.esh-basket-item--middle{line-height:8rem}@media screen and (max-width:1024px){.esh-basket-item--middle{line-height:1rem}}.esh-basket-item--mark{color:#00a69c}.esh-basket-image{height:8rem}.esh-basket-input{line-height:1rem;width:100%}.esh-basket-checkout{background-color:#83d01b;border:0;border-radius:0;color:#fff;display:inline-block;font-size:1rem;font-weight:400;margin-top:1rem;padding:1rem 1.5rem;text-align:center;text-transform:uppercase;transition:all .35s}.esh-basket-checkout:hover{background-color:#4a760f;transition:all .35s}.esh-basket-checkout:visited{color:#fff}.esh-basketstatus{cursor:pointer;display:inline-block;float:right;position:relative;transition:all .35s}.esh-basketstatus.is-disabled{opacity:.5;pointer-events:none}.esh-basketstatus-image{height:36px;margin-top:.5rem}.esh-basketstatus-badge{background-color:#83d01b;border-radius:50%;color:#fff;display:block;height:1.5rem;left:50%;position:absolute;text-align:center;top:0;transform:translateX(-38%);transition:all .35s;width:1.5rem}.esh-basketstatus-badge-inoperative{background-color:#f00;border-radius:50%;color:#fff;display:block;height:1.5rem;left:50%;position:absolute;text-align:center;top:0;transform:translateX(-38%);transition:all .35s;width:1.5rem}.esh-basketstatus:hover .esh-basketstatus-badge{background-color:transparent;color:#75b918;transition:all .35s}.esh-catalog-hero{background-image:url("../images/main_banner.png");background-size:cover;height:260px;width:100%}.esh-catalog-title{position:relative;top:74.28571px}.esh-catalog-filters{background-color:#00a69c;height:65px}.esh-catalog-filter{-webkit-appearance:none;background-color:transparent;border-color:#00d9cc;color:#fff;cursor:pointer;margin-right:1rem;margin-top:.5rem;min-width:140px;outline-color:#83d01b;padding-bottom:0;padding-left:.5rem;padding-right:.5rem;padding-top:1.5rem}.esh-catalog-filter option{background-color:#00a69c}.esh-catalog-label{display:inline-block;position:relative;z-index:0}.esh-catalog-label::before{color:rgba(255,255,255,.5);content:attr(data-title);font-size:.65rem;margin-left:.5rem;margin-top:.65rem;position:absolute;text-transform:uppercase;z-index:1}.esh-catalog-label::after{background-image:url("../images/arrow-down.png");content:'';height:7px;position:absolute;right:1.5rem;top:2.5rem;width:10px;z-index:1}.esh-catalog-send{background-color:#83d01b;color:#fff;cursor:pointer;font-size:1rem;margin-top:-1.5rem;transition:all .35s}.esh-catalog-send:hover{background-color:#4a760f;transition:all .35s}.esh-catalog-items{margin-top:1rem}.esh-catalog-item{margin-bottom:1.5rem;text-align:center;width:33%;display:inline-block;float:none !important}@media screen and (max-width:1024px){.esh-catalog-item{width:50%}}@media screen and (max-width:768px){.esh-catalog-item{width:100%}}.esh-catalog-thumbnail{max-width:370px;width:100%}.esh-catalog-button{background-color:#83d01b;border:0;color:#fff;cursor:pointer;font-size:1rem;height:3rem;margin-top:1rem;transition:all .35s;width:80%}.esh-catalog-button.is-disabled{opacity:.5;pointer-events:none}.esh-catalog-button:hover{background-color:#4a760f;transition:all .35s}.esh-catalog-name{font-size:1rem;font-weight:300;margin-top:.5rem;text-align:center;text-transform:uppercase}.esh-catalog-price{font-size:28px;font-weight:900;text-align:center}.esh-catalog-price::before{content:'$'}.esh-orders{min-height:80vh;overflow-x:hidden}.esh-orders-header{background-color:#00a69c;height:4rem}.esh-orders-back{color:rgba(255,255,255,.4);line-height:4rem;text-decoration:none;text-transform:uppercase;transition:color .35s}.esh-orders-back:hover{color:#fff;transition:color .35s}.esh-orders-titles{padding-bottom:1rem;padding-top:2rem}.esh-orders-title{text-transform:uppercase}.esh-orders-items{height:2rem;line-height:2rem;position:relative}.esh-orders-items:nth-of-type(2n+1):before{background-color:#eef;content:'';height:100%;left:0;margin-left:-100vw;position:absolute;top:0;width:200vw;z-index:-1}.esh-orders-item{font-weight:300}.esh-orders-item--hover{opacity:0;pointer-events:none}.esh-orders-items:hover .esh-orders-item--hover{opacity:1;pointer-events:all}.esh-orders-link{color:#83d01b;text-decoration:none;transition:color .35s}.esh-orders-link:hover{color:#75b918;transition:color .35s}.esh-orders-detail-section{padding-bottom:30px}.esh-orders-detail-title{font-size:25px} \ No newline at end of file diff --git a/src/Web/wwwroot/images/products/5.jpg b/src/Web/wwwroot/images/products/5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..690649b4568525d574195153a55b18c8c104f37e GIT binary patch literal 27468 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 literal 0 HcmV?d00001