Implemented application profile and dark mode.

Added a specific cascading dependencies component.

Added some content to main page.

Added configuration page and persistence for it.

Code restructured (moved some code into separate components).
This commit is contained in:
Harrison Deng 2021-05-31 16:39:52 -05:00
parent b311206ff1
commit 7d4be012cd
29 changed files with 836 additions and 193 deletions

View File

@ -1,9 +1,8 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData">
@if (modulesLoaded)
{
<CascadingValue Value="shops" Name="Shops">
<CascadingDependencies>
<Content>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" Context="authState">
<NotAuthorized>
@if (!authState.User.Identity.IsAuthenticated)
@ -16,26 +15,25 @@
}
</NotAuthorized>
</AuthorizeRouteView>
</CascadingValue>
}
else
{
<div class="d-flex flex-column align-items-center justify-content-center" style="width: 100vw; height: 100vh;">
<div class="my-1">
<div class="spinner-border text-success" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<div class="my-1">
Downloading shop modules...
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</Content>
<LoadingContent Context="Status">
<div class="d-flex flex-column align-items-center justify-content-center" style="width: 100vw; height: 100vh;">
<div class="my-2">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
}
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
<div class="my-2">
Loading @Status...
</div>
</div>
</LoadingContent>
</CascadingDependencies>
</CascadingAuthenticationState>

View File

@ -0,0 +1,22 @@
using MultiShop.Shared.Models;
namespace MultiShop.Client.Extensions
{
public static class applicationProfileExtensions
{
public static string GetButtonCssClass(this ApplicationProfile applicationProfile, string otherClasses = "", bool outline = false) {
if (outline) {
return otherClasses + (applicationProfile.DarkMode ? " btn btn-outline-light" : " btn btn-outline-dark");
}
return otherClasses + (applicationProfile.DarkMode ? " btn btn-light" : " btn btn-dark");
}
public static string GetPageCssClass(this ApplicationProfile applicationProfile, string otherClasses = "") {
return otherClasses + (applicationProfile.DarkMode ? " text-white bg-dark" : " text-dark bg-white");
}
public static string GetNavCssClass(this ApplicationProfile applicationProfile, string otherClasses = "") {
return otherClasses + (applicationProfile.DarkMode ? " navbar-dark bg-dark" : " navbar-light bg-light");
}
}
}

View File

@ -1,53 +1,48 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using MultiShop.Client.Module;
using MultiShop.Shop.Framework;
namespace MultiShop.Client
namespace MultiShop.Client.Module
{
public partial class App : IDisposable
public class ShopModuleLoader
{
[Inject]
private IHttpClientFactory HttpFactory { get; set; }
[Inject]
private ILogger<App> Logger {get; set; }
private HttpClient http;
private ILogger logger;
private string[] moduleNames;
private string[] dependencyNames;
private bool modulesLoaded = false;
private Dictionary<string, IShop> shops = new Dictionary<string, IShop>();
protected override async Task OnInitializedAsync()
public ShopModuleLoader(HttpClient http, ILogger logger)
{
await base.OnInitializedAsync();
await DownloadShopModules();
this.http = http;
this.logger = logger;
}
private async Task DownloadShopModules()
{
HttpClient http = HttpFactory.CreateClient("Public-MultiShop.ServerAPI");
private async Task DownloadAssembliesList() {
moduleNames = await http.GetFromJsonAsync<string[]>("ShopModule/Modules");
dependencyNames = await http.GetFromJsonAsync<string[]>("ShopModule/Dependencies");
}
private async Task<IReadOnlyDictionary<string, byte[]>> DownloadShopModuleAssemblies() {
Dictionary<string, byte[]> assemblyData = new Dictionary<string, byte[]>();
string[] moduleNames = await http.GetFromJsonAsync<string[]>("ShopModule/Modules");
string[] dependencyNames = await http.GetFromJsonAsync<string[]>("ShopModule/Dependencies");
Dictionary<Task<byte[]>, string> downloadTasks = new Dictionary<Task<byte[]>, string>();
Logger.LogInformation("Beginning to download shop modules...");
logger.LogInformation("Beginning to download shop modules...");
foreach (string moduleName in moduleNames)
{
Logger.LogDebug($"Downloading shop: {moduleName}");
logger.LogDebug($"Downloading shop: {moduleName}");
downloadTasks.Add(http.GetByteArrayAsync("shopModule/Modules/" + moduleName), moduleName);
}
Logger.LogInformation("Beginning to download shop module dependencies...");
logger.LogInformation("Beginning to download shop module dependencies...");
foreach (string depName in dependencyNames)
{
Logger.LogDebug($"Downloading shop module dependency: {depName}");
logger.LogDebug($"Downloading shop module dependency: {depName}");
downloadTasks.Add(http.GetByteArrayAsync("ShopModule/Dependencies/" + depName), depName);
}
@ -55,16 +50,26 @@ namespace MultiShop.Client
{
Task<byte[]> downloadTask = await Task.WhenAny(downloadTasks.Keys);
assemblyData.Add(downloadTasks[downloadTask], await downloadTask);
Logger.LogDebug($"Shop module \"{downloadTasks[downloadTask]}\" completed downloading.");
logger.LogDebug($"Shop module \"{downloadTasks[downloadTask]}\" completed downloading.");
downloadTasks.Remove(downloadTask);
}
Logger.LogInformation($"Downloaded {assemblyData.Count} assemblies in total.");
logger.LogInformation($"Downloaded {assemblyData.Count} assemblies in total.");
return assemblyData;
}
public async Task<IReadOnlyDictionary<string, IShop>> GetShops()
{
await DownloadAssembliesList();
Dictionary<string, IShop> shops = new Dictionary<string, IShop>();
IReadOnlyDictionary<string, byte[]> assemblyData = await DownloadShopModuleAssemblies();
ShopModuleLoadContext context = new ShopModuleLoadContext(assemblyData);
Logger.LogInformation("Beginning to load shop modules.");
logger.LogInformation("Beginning to load shop modules.");
foreach (string moduleName in moduleNames)
{
Logger.LogDebug($"Attempting to load shop module: \"{moduleName}\"");
logger.LogDebug($"Attempting to load shop module: \"{moduleName}\"");
Assembly moduleAssembly = context.LoadFromAssemblyName(new AssemblyName(moduleName));
bool shopLoaded = false;
foreach (Type type in moduleAssembly.GetTypes())
@ -75,32 +80,25 @@ namespace MultiShop.Client
shopLoaded = true;
shop.Initialize();
shops.Add(shop.ShopName, shop);
Logger.LogDebug($"Added shop: {shop.ShopName}");
logger.LogDebug($"Added shop: {shop.ShopName}");
}
}
}
if (!shopLoaded) {
Logger.LogWarning($"Module \"{moduleName}\" was reported to be a shop module, but did not contain a shop interface. Please report this to the site administrator.");
logger.LogWarning($"Module \"{moduleName}\" was reported to be a shop module, but did not contain a shop interface. Please report this to the site administrator.");
}
}
Logger.LogInformation($"Shop module loading complete. Loaded a total of {shops.Count} shops.");
modulesLoaded = true;
logger.LogInformation($"Shop module loading complete. Loaded a total of {shops.Count} shops.");
foreach (string assemblyName in context.UseCounter.Keys)
{
int usage = context.UseCounter[assemblyName];
Logger.LogDebug($"\"{assemblyName}\" was used {usage} times.");
logger.LogDebug($"\"{assemblyName}\" was used {usage} times.");
if (usage <= 0) {
Logger.LogWarning($"\"{assemblyName}\" was not used. Please report this to the site administrator.");
logger.LogWarning($"\"{assemblyName}\" was not used. Please report this to the site administrator.");
}
}
}
public void Dispose()
{
foreach (string name in shops.Keys)
{
shops[name].Dispose();
}
return shops;
}
}
}

View File

@ -5,7 +5,7 @@
<RemoteAuthenticatorView Action="@Action">
<LoggingIn>
<div class="d-flex flex-column align-items-center justify-content-center" style="width: 100%;">
<div class="container my-3 d-flex flex-column align-items-center justify-content-center">
<div class="my-1">
<div class="spinner-grow text-primary" role="status">
<span class="sr-only">Loading...</span>
@ -17,7 +17,7 @@
</div>
</LoggingIn>
<Registering>
<div class="d-flex flex-column align-items-center justify-content-center" style="width: 100%;">
<div class="container my-3 d-flex flex-column align-items-center justify-content-center">
<div class="my-1">
<div class="spinner-grow text-primary" role="status">
<span class="sr-only">Loading...</span>
@ -28,6 +28,18 @@
</div>
</div>
</Registering>
<CompletingLoggingIn>
<div class="container my-3 d-flex flex-column align-items-center justify-content-center">
<div class="my-1">
<div class="spinner-grow text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<div class="my-1 text-muted">
Hang on just a sec. We're logging you in!
</div>
</div>
</CompletingLoggingIn>
</RemoteAuthenticatorView>
@code{

View File

@ -0,0 +1,55 @@
@using MultiShop.Client.Extensions
@page "/configure"
<div class="d-flex">
<nav class=@ApplicationProfile.GetNavCssClass()>
<div class=@NavMenuCssClass>
<ul class="nav flex-column">
@foreach (Section section in sectionOrder)
{
<li class=@GetNavItemCssClass(section)>
<button class="btn btn-link nav-link" type="button" @onclick=@(() => currentSection = section)>@sectionNames[section]</button>
</li>
}
</ul>
</div>
<button class="nav-toggler" type="button" @onclick="@(() => collapseNavMenu = !collapseNavMenu)">
<span class="navbar-toggler-icon"></span>
</button>
</nav>
<div class="container">
@switch (currentSection)
{
case Section.Opening:
<h1 class="mb-1">Configuration</h1>
<small class="text-muted">For all your control-asserting needs.</small>
<p>You can change how the app looks and operates. These changes will actually be saved to your account if you're logged in so your changes will be with you across different devices! Otherwise, we'll just stash them in cookies. Also know for your convenience, each option is followed by a short description of what changing that option will do. Get started by selecting a section!</p>
break;
case Section.UI:
<h4 class="card-title">UI</h4>
<div>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="darkmodeSwitch" @onclick="@(async () => {ApplicationProfile.DarkMode = !ApplicationProfile.DarkMode; await LayoutStateChangeNotifier.LayoutHasChanged();})" checked=@ApplicationProfile.DarkMode>
<label class="custom-control-label" for="darkmodeSwitch">Enable to dark mode</label>
</div>
<p>Changes the UI to a dark theme. Pretty self-explanatory.</p>
</div>
break;
case Section.Search:
<h4 class="card-title">Search</h4>
<div>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="cacheSearchSwitch" @bind=ApplicationProfile.CacheCommonSearches>
<label class="custom-control-label" for="cacheSearchSwitch">Cache common searches</label>
</div>
<p>We will store results from commonly searched queries to reproduce repeated searches faster. to make sure prices are relevant, queries older than a few minutes will be removed.</p>
</div>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="searchHistorySwitch" @bind=ApplicationProfile.EnableSearchHistory>
<label class="custom-control-label" for="searchHistorySwitch">Save search history.</label>
</div>
break;
}
</div>
</div>

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using MultiShop.Client.Services;
using MultiShop.Shared.Models;
namespace MultiShop.Client.Pages
{
public partial class Configuration : IAsyncDisposable
{
[Inject]
private ILogger<Configuration> Logger { get; set; }
[Inject]
private LayoutStateChangeNotifier LayoutStateChangeNotifier { get; set; }
[Inject]
private HttpClient Http { get; set; }
[CascadingParameter]
private Task<AuthenticationState> AuthenticationStateTask { get; set; }
[CascadingParameter(Name = "ApplicationProfile")]
private ApplicationProfile ApplicationProfile { get; set; }
private bool collapseNavMenu;
private string NavMenuCssClass => (collapseNavMenu ? "collapse" : "");
private enum Section
{
Opening, UI, Search
}
private Section currentSection = Section.Opening;
private Dictionary<Section, string> sectionNames = new Dictionary<Section, string>() {
{Section.Opening, "Info"},
{Section.UI, "UI"},
{Section.Search, "Search"}
};
private List<Section> sectionOrder = new List<Section>() {
Section.Opening,
Section.UI,
Section.Search
};
private string GetNavItemCssClass(Section section)
{
return "nav-item" + ((section == currentSection) ? " active" : null);
}
public async ValueTask DisposeAsync()
{
AuthenticationState authenticationState = await AuthenticationStateTask;
if (authenticationState.User.Identity.IsAuthenticated) {
Logger.LogDebug($"User is authenticated. Attempting to save configuration to server.");
await Http.PutAsJsonAsync("Profile/Application", ApplicationProfile);
}
}
}
}

View File

@ -0,0 +1,48 @@
nav {
position: static;
padding: 0.5rem;
height: fit-content;
}
nav .nav-toggler {
display: none;
border-style: none;
padding: 0px;
background-color: transparent;
}
.collapse {
display: block;
}
.navbar-light .nav-item.active .btn {
color: black;
}
.nav .nav-item .btn {
color: gray;
}
.navbar-dark .nav-item.active .btn {
color: white;
}
@media (max-width: 50rem) {
nav .nav-toggler {
display: block;
}
nav {
position: fixed;
padding: 1rem;
bottom: 1rem;
left: 1rem;
border-radius: 12px;
}
.collapse {
display: none;
}
}

View File

@ -1,4 +1,14 @@
@page "/"
@* TODO: Add main page content.*@
<h1>Welcome to MultiShop!</h1>
<div>
<h1>Welcome to MultiShop!</h1>
</div>
<div>
<h2>What is MultiShop?</h2>
<p>MultiShop is an app that allows users to easily check multiple online retailers, primarily for electronic components. On request, the app searches, aggregates and sorts all the most important information about the products it finds while searching through sites for you! MultiShop is intended to be a small light-weight app the runs on the clients computer. Therefore, all actions and processes are actually reliant on your computer. Additionally, MultiShop does not crawl sites and save their products like a typical search engine. To provide the latest prices, all searches are performed (near) live on command.</p>
</div>
<div>
<h2>How does it work?</h2>
<p>MultiShop is a simple program that takes care of looking through product listings for you. That's all. As all tasks are executed locally on your computer MultiShop is like any other program, except that it is transient, and automatically loaded when you visit this website. The shop has a variety of modules meant to search different shops by essentially querying the shop as the user would normally, and then scanning the results.</p>
</div>

View File

@ -6,9 +6,10 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using MultiShop.Client.Shared;
using MultiShop.Client.Extensions;
using MultiShop.Client.Listing;
using MultiShop.Client.Services;
using MultiShop.Client.Shared;
using MultiShop.Shared.Models;
using MultiShop.Shop.Framework;
@ -16,6 +17,9 @@ namespace MultiShop.Client.Pages
{
public partial class Search : IAsyncDisposable
{
[Inject]
private LayoutStateChangeNotifier LayoutStateChangeNotifier { get; set; }
[Inject]
private ILogger<Search> Logger { get; set; }
@ -23,10 +27,10 @@ namespace MultiShop.Client.Pages
private HttpClient Http { get; set; }
[CascadingParameter]
Task<AuthenticationState> AuthenticationStateTask { get; set; }
private Task<AuthenticationState> AuthenticationStateTask { get; set; }
[CascadingParameter(Name = "Shops")]
public Dictionary<string, IShop> Shops { get; set; }
public IReadOnlyDictionary<string, IShop> Shops { get; set; }
[Parameter]
public string Query { get; set; }
@ -43,30 +47,45 @@ namespace MultiShop.Client.Pages
private List<ProductListingInfo> listings = new List<ProductListingInfo>();
private int resultsChecked = 0;
protected override void OnInitialized()
{
base.OnInitialized();
LayoutStateChangeNotifier.Notify += UpdateState;
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
AuthenticationState authState = await AuthenticationStateTask;
if (authState.User.Identity.IsAuthenticated) {
if (authState.User.Identity.IsAuthenticated)
{
Logger.LogDebug($"User \"{authState.User.Identity.Name}\" is authenticated. Checking for saved profiles.");
HttpResponseMessage searchProfileResponse = await Http.GetAsync("Profile/Search");
if (searchProfileResponse.IsSuccessStatusCode) {
if (searchProfileResponse.IsSuccessStatusCode)
{
activeSearchProfile = await searchProfileResponse.Content.ReadFromJsonAsync<SearchProfile>();
} else {
}
else
{
Logger.LogWarning("Could not load search profile from server. Using default.");
activeSearchProfile = new SearchProfile();
}
HttpResponseMessage resultsProfileResponse = await Http.GetAsync("Profile/Results");
if (resultsProfileResponse.IsSuccessStatusCode) {
if (resultsProfileResponse.IsSuccessStatusCode)
{
activeResultsProfile = await resultsProfileResponse.Content.ReadFromJsonAsync<ResultsProfile>();
} else {
}
else
{
Logger.LogWarning("Could not load results profile from server. Using default.");
activeResultsProfile = new ResultsProfile();
}
} else {
}
else
{
activeSearchProfile = new SearchProfile();
activeResultsProfile = new ResultsProfile();
}
@ -82,9 +101,11 @@ namespace MultiShop.Client.Pages
}
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender) {
if (firstRender)
{
searchBar.Query = Query;
searchBar.Searching = true;
await PerformSearch(Query);
@ -247,13 +268,21 @@ namespace MultiShop.Client.Pages
StateHasChanged();
}
private async Task UpdateState() {
await InvokeAsync(() => {
StateHasChanged();
});
}
public async ValueTask DisposeAsync()
{
AuthenticationState authState = await AuthenticationStateTask;
if (authState.User.Identity.IsAuthenticated) {
if (authState.User.Identity.IsAuthenticated)
{
await Http.PutAsJsonAsync("Profile/Search", activeSearchProfile);
await Http.PutAsJsonAsync("Profile/Results", activeResultsProfile);
}
LayoutStateChangeNotifier.Notify -= UpdateState;
}
public class Status

View File

@ -8,6 +8,9 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using MultiShop.Client.Module;
using MultiShop.Shop.Framework;
using MultiShop.Client.Services;
namespace MultiShop.Client
{
@ -15,7 +18,9 @@ namespace MultiShop.Client
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
builder.RootComponents.Add<App>("#app");
@ -26,15 +31,13 @@ namespace MultiShop.Client
Action<HttpClient> configureClient = client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
builder.Services.AddHttpClient("Public-MultiShop.ServerAPI", configureClient);
IReadOnlyDictionary<string, string> webApiConfig = null;
using (HttpClient client = new HttpClient())
{
configureClient.Invoke(client);
webApiConfig = await client.GetFromJsonAsync<IReadOnlyDictionary<string, string>>("PublicApiSettings");
configureClient.Invoke(client);
builder.Configuration.AddInMemoryCollection(await client.GetFromJsonAsync<IReadOnlyDictionary<string, string>>("PublicApiSettings"));
}
builder.Configuration.AddInMemoryCollection(webApiConfig);
builder.Services.AddSingleton<LayoutStateChangeNotifier>();
builder.Services.AddApiAuthorization();

View File

@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;
namespace MultiShop.Client.Services
{
public class LayoutStateChangeNotifier
{
public event Func<Task> Notify;
public async Task LayoutHasChanged() {
await Notify?.Invoke();
}
}
}

View File

@ -0,0 +1,27 @@
@using Microsoft.Extensions.Configuration
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager
@inject IConfiguration Configuration
<AuthorizeView Context="auth">
<Authorized>
<a class="mx-2" href="authentication/profile">Hello, @auth.User.Identity.Name!</a>
<button class="btn btn-link mx-2" @onclick="BeginSignOut">Log out</button>
</Authorized>
<NotAuthorized>
@if (Configuration["IdentityServer:Registration"].Equals("enabled"))
{
<a class="mx-2" href="authentication/register">Register</a>
}
<a class="mx-2" href="authentication/login">Log in</a>
</NotAuthorized>
</AuthorizeView>
@code {
private async Task BeginSignOut(MouseEventArgs args)
{
await SignOutManager.SetSignOutState();
Navigation.NavigateTo("authentication/logout");
}
}

View File

@ -0,0 +1,14 @@
@using Microsoft.Extensions.Logging
@using Module
@using MultiShop.Shared.Models
@if (loadingDisplay == null)
{
<CascadingValue Value="@applicationProfile" Name="ApplicationProfile">
<CascadingValue Value="@shops" Name="Shops">
@Content
</CascadingValue>
</CascadingValue>
} else {
@LoadingContent(loadingDisplay)
}

View File

@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using MultiShop.Client.Module;
using MultiShop.Shared.Models;
using MultiShop.Shop.Framework;
namespace MultiShop.Client.Shared
{
public partial class CascadingDependencies : IDisposable
{
[Inject]
private ILogger<CascadingDependencies> Logger { get; set; }
[Inject]
private IHttpClientFactory HttpClientFactory { get; set; }
[CascadingParameter]
private Task<AuthenticationState> AuthenticationStateTask { get; set; }
[Parameter]
public RenderFragment<string> LoadingContent { get; set; }
[Parameter]
public RenderFragment Content { get; set; }
private bool disposedValue;
private string loadingDisplay;
private IReadOnlyDictionary<string, IShop> shops;
private ApplicationProfile applicationProfile;
protected override async Task OnInitializedAsync()
{
loadingDisplay = "";
await base.OnInitializedAsync();
await DownloadShops(HttpClientFactory.CreateClient("Public-MultiShop.ServerAPI"));
await DownloadApplicationProfile(HttpClientFactory.CreateClient("MultiShop.ServerAPI"));
loadingDisplay = null;
}
private async Task DownloadShops(HttpClient http)
{
loadingDisplay = "shops";
ShopModuleLoader loader = new ShopModuleLoader(http, Logger);
shops = await loader.GetShops();
}
private async Task DownloadApplicationProfile(HttpClient http)
{
loadingDisplay = "profile";
AuthenticationState authState = await AuthenticationStateTask;
if (authState.User.Identity.IsAuthenticated)
{
Logger.LogDebug($"User is logged in. Attempting to fetch application profile.");
HttpResponseMessage response = await http.GetAsync("Profile/Application");
if (response.IsSuccessStatusCode)
{
applicationProfile = await response.Content.ReadFromJsonAsync<ApplicationProfile>();
}
}
if (applicationProfile == null) applicationProfile = new ApplicationProfile();
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
foreach (string shopName in shops.Keys)
{
shops[shopName].Dispose();
}
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,85 @@
@using MultiShop.Client.Extensions
@using MultiShop.Shared.Models
@using MultiShop.Client.Services
@implements IDisposable
@inject LayoutStateChangeNotifier LayoutStateChangeNotifier
<nav class=@ApplicationProfile.GetNavCssClass("navbar navbar-expand-lg")>
<a class="navbar-brand" href="">
@BrandContent
</a>
<button class="navbar-toggler" type="button" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class=@NavMenuCssClass>
<ul class="navbar-nav mr-auto">
@foreach (string item in Items)
{
<li class="nav-item">
<NavLink class="nav-link" href=@item Match=@(string.IsNullOrEmpty(item) ? NavLinkMatch.All : NavLinkMatch.Prefix)>
@ItemTemplate(item)
</NavLink>
</li>
}
</ul>
@LatterContent
</div>
</nav>
@code {
[CascadingParameter(Name = "ApplicationProfile")]
private ApplicationProfile ApplicationProfile { get; set; }
private bool collapseNavMenu = true;
private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse";
[Parameter]
public IList<string> Items { get; set; }
[Parameter]
public RenderFragment BrandContent { get; set; }
[Parameter]
public RenderFragment<string> ItemTemplate { get; set; }
[Parameter]
public RenderFragment LatterContent { get; set; }
private bool disposed;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
protected override void OnInitialized()
{
base.OnInitialized();
LayoutStateChangeNotifier.Notify += UpdateState;
}
private async Task UpdateState() {
await InvokeAsync(() => {
StateHasChanged();
});
}
protected virtual void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
{
}
LayoutStateChangeNotifier.Notify -= UpdateState;
disposed = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -0,0 +1,42 @@
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.Extensions.Configuration
@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager
@inject IConfiguration Configuration
<HorizontalNavMenuTemplate Items=@PlaceOrder>
<BrandContent>
<img src="images/100x100.png" width="30" height="30" class="d-inline-block align-top">
MultiShop
</BrandContent>
<ItemTemplate Context="place">
@PlaceNames[place]
</ItemTemplate>
<LatterContent>
<AuthenticationDisplay />
</LatterContent>
</HorizontalNavMenuTemplate>
@code {
private bool collapseNavMenu = true;
private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse";
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
private Dictionary<string, string> PlaceNames = new Dictionary<string, string>() {
{"","Home"},
{"search", "Search"},
{"configure", "Options"}
};
private List<string> PlaceOrder = new List<string>() {
"",
"search",
"configure"
};
}

View File

@ -1,10 +1,51 @@
@using MultiShop.Client.Extensions
@using MultiShop.Shared.Models
@using MultiShop.Client.Services
@inherits LayoutComponentBase
@implements IDisposable
@inject LayoutStateChangeNotifier LayoutStateChangeNotifier
<div class="page">
<NavMenu />
<div class="main">
<div class="content px-4">
@Body
</div>
<div class=@ApplicationProfile.GetPageCssClass("page")>
<HorizontalSiteNav />
<div class="content">
@Body
</div>
</div>
</div>
@code {
[CascadingParameter(Name = "ApplicationProfile")]
private ApplicationProfile ApplicationProfile { get; set; }
private bool disposed;
protected override void OnInitialized()
{
base.OnInitialized();
LayoutStateChangeNotifier.Notify += UpdateState;
}
private async Task UpdateState()
{
await InvokeAsync(() =>
{
StateHasChanged();
});
}
protected virtual void Dispose(bool disposing)
{
if (!disposed)
{
if (disposing)
{
}
LayoutStateChangeNotifier.Notify -= UpdateState;
disposed = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -3,18 +3,3 @@
display: flex;
flex-direction: column;
}
.main {
flex: 1;
}
@media (max-width: 640.98px) {
.top-row:not(.auth) {
display: none;
}
.top-row.auth {
justify-content: space-between;
}
}

View File

@ -1,74 +0,0 @@
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.Extensions.Configuration
@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager
@inject IConfiguration Configuration
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="">
<img src="100x100.png" width="30" height="30" class="d-inline-block align-top">
MultiShop
</a>
<button class="navbar-toggler" type="button" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
<div class=@NavMenuCssClass>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="search" Match="NavLinkMatch.Prefix">
<span class="oi oi-magnifying-glass"></span> Search
</NavLink>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<AuthorizeView Context="auth">
<Authorized>
<li class="nav-item">
<a class="nav-link" href="authentication/profile">Hello, @auth.User.Identity.Name!</a>
</li>
<li class="nav-item">
<button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button>
</li>
</Authorized>
<NotAuthorized>
@if (Configuration["IdentityServer:Registration"].Equals("enabled"))
{
<li class="nav-item">
<a class="nav-link" href="authentication/register">Register</a>
</li>
}
<li class="nav-item">
<a class="nav-link" href="authentication/login">Log in</a>
</li>
</NotAuthorized>
</AuthorizeView>
</ul>
</div>
</nav>
@code {
private bool collapseNavMenu = true;
private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse";
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
private async Task BeginSignOut(MouseEventArgs args)
{
await SignOutManager.SetSignOutState();
Navigation.NavigateTo("authentication/logout");
}
}

View File

@ -4,8 +4,30 @@ html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
#app {
min-height: 100vh;
}
.page {
min-height: inherit;
}
.bg-dark {
background-color: #262626 !important;
}
nav.bg-dark {
background-color: #1A1A1A !important;
}
.content {
padding-top: 1.5rem;
padding-right: 2rem;
padding-left: 2rem;
}
.bg-dark .card {
background-color: #1F1F1F;
}
#blazor-error-ui {

View File

Before

Width:  |  Height:  |  Size: 255 B

After

Width:  |  Height:  |  Size: 255 B

View File

@ -14,12 +14,12 @@
<body>
<div id="app">
<div class="d-flex flex-column align-items-center justify-content-center" style="width: 100vw; height: 100vh;">
<div class="my-1">
<div class="spinner-border text-primary" role="status">
<div class="my-2">
<div class="spinner-border text-secondary" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<div class="my-1 text-muted">
<div class="my-2 text-muted">
Loading...
</div>
</div>

View File

@ -1,7 +1,10 @@
using System.Text.Json;
using System.Threading.Tasks;
using Castle.Core.Logging;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MultiShop.Server.Data;
using MultiShop.Server.Models;
using MultiShop.Shared.Models;
@ -14,12 +17,14 @@ namespace MultiShop.Server.Controllers
[Route("[controller]")]
public class ProfileController : ControllerBase
{
private ILogger<ProfileController> logger;
private UserManager<ApplicationUser> userManager;
private ApplicationDbContext dbContext;
public ProfileController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext)
public ProfileController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext, ILogger<ProfileController> logger)
{
this.userManager = userManager;
this.dbContext = dbContext;
this.logger = logger;
}
[HttpGet]
@ -36,6 +41,14 @@ namespace MultiShop.Server.Controllers
return Ok(userModel.ResultsProfile);
}
[HttpGet]
[Route("Application")]
public async Task<IActionResult> GetApplicationProfile() {
ApplicationUser userModel = await userManager.GetUserAsync(User);
logger.LogInformation(JsonSerializer.Serialize(userModel.ApplicationProfile));
return Ok(userModel.ApplicationProfile);
}
[HttpPut]
[Route("Search")]
public async Task<IActionResult> PutSearchProfile(SearchProfile searchProfile) {
@ -52,12 +65,25 @@ namespace MultiShop.Server.Controllers
[Route("Results")]
public async Task<IActionResult> PutResultsProfile(ResultsProfile resultsProfile) {
ApplicationUser userModel = await userManager.GetUserAsync(User);
if (userModel.ResultsProfile.Id != resultsProfile.Id) {
if (userModel.ResultsProfile.Id != resultsProfile.Id || userModel.Id != resultsProfile.ApplicationUserId) {
return BadRequest();
}
dbContext.Entry(userModel.ResultsProfile).CurrentValues.SetValues(resultsProfile);
await userManager.UpdateAsync(userModel);
return NoContent();
}
[HttpPut]
[Route("Application")]
public async Task<IActionResult> PutApplicationProfile(ApplicationProfile applicationProfile) {
ApplicationUser userModel = await userManager.GetUserAsync(User);
logger.LogInformation(JsonSerializer.Serialize(applicationProfile));
if (userModel.ApplicationProfile.Id != applicationProfile.Id || userModel.Id != applicationProfile.ApplicationUserId) {
return BadRequest();
}
dbContext.Entry(userModel.ApplicationProfile).CurrentValues.SetValues(applicationProfile);
await userManager.UpdateAsync(userModel);
return NoContent();
}
}
}

View File

@ -9,7 +9,7 @@ using MultiShop.Server.Data;
namespace MultiShop.Server.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20210525224658_InitialCreate")]
[Migration("20210531175621_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -317,6 +317,32 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("MultiShop.Shared.Models.ApplicationProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<bool>("CacheCommonSearches")
.HasColumnType("INTEGER");
b.Property<bool>("DarkMode")
.HasColumnType("INTEGER");
b.Property<bool>("EnableSearchHistory")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ApplicationProfile");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.Property<int>("Id")
@ -452,6 +478,13 @@ namespace MultiShop.Server.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("MultiShop.Shared.Models.ApplicationProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("ApplicationProfile")
.HasForeignKey("MultiShop.Shared.Models.ApplicationProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
@ -468,6 +501,9 @@ namespace MultiShop.Server.Data.Migrations
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Navigation("ApplicationProfile")
.IsRequired();
b.Navigation("ResultsProfile")
.IsRequired();

View File

@ -106,6 +106,28 @@ namespace MultiShop.Server.Data.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ApplicationProfile",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: true),
DarkMode = table.Column<bool>(type: "INTEGER", nullable: false),
CacheCommonSearches = table.Column<bool>(type: "INTEGER", nullable: false),
EnableSearchHistory = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApplicationProfile", x => x.Id);
table.ForeignKey(
name: "FK_ApplicationProfile_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
@ -245,6 +267,12 @@ namespace MultiShop.Server.Data.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ApplicationProfile_ApplicationUserId",
table: "ApplicationProfile",
column: "ApplicationUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
@ -323,6 +351,9 @@ namespace MultiShop.Server.Data.Migrations
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApplicationProfile");
migrationBuilder.DropTable(
name: "AspNetRoleClaims");

View File

@ -315,6 +315,32 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("MultiShop.Shared.Models.ApplicationProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<bool>("CacheCommonSearches")
.HasColumnType("INTEGER");
b.Property<bool>("DarkMode")
.HasColumnType("INTEGER");
b.Property<bool>("EnableSearchHistory")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("ApplicationProfile");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.Property<int>("Id")
@ -450,6 +476,13 @@ namespace MultiShop.Server.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("MultiShop.Shared.Models.ApplicationProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
.WithOne("ApplicationProfile")
.HasForeignKey("MultiShop.Shared.Models.ApplicationProfile", "ApplicationUserId");
});
modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{
b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
@ -466,6 +499,9 @@ namespace MultiShop.Server.Data.Migrations
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{
b.Navigation("ApplicationProfile")
.IsRequired();
b.Navigation("ResultsProfile")
.IsRequired();

View File

@ -11,5 +11,8 @@ namespace MultiShop.Server.Models
[Required]
public virtual ResultsProfile ResultsProfile { get; private set; } = new ResultsProfile();
[Required]
public virtual ApplicationProfile ApplicationProfile {get; private set; } = new ApplicationProfile();
}
}

View File

@ -0,0 +1,15 @@
namespace MultiShop.Shared.Models
{
public class ApplicationProfile
{
public int Id { get; set; }
public string ApplicationUserId { get; set; }
public bool DarkMode { get; set; }
public bool CacheCommonSearches { get; set; } = true;
public bool EnableSearchHistory { get; set; } = true;
}
}

View File

@ -48,7 +48,7 @@ namespace MultiShop.Shared.Models
if (EnableMaxShippingFee) _maxShippingFee = value;
}
}
public bool KeepUnknownShipping { get; set; }
public bool KeepUnknownShipping { get; set; } = true;
[Required]
public ShopToggler ShopStates { get; set; } = new ShopToggler();
@ -115,5 +115,11 @@ namespace MultiShop.Shared.Models
{
return Id;
}
public SearchProfile DeepCopy() {
SearchProfile profile = (SearchProfile)MemberwiseClone();
profile.ShopStates = ShopStates.Clone();
return profile;
}
}
}