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> <CascadingAuthenticationState>
<CascadingDependencies>
<Content>
<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true"> <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
<Found Context="routeData"> <Found Context="routeData">
@if (modulesLoaded)
{
<CascadingValue Value="shops" Name="Shops">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" Context="authState"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" Context="authState">
<NotAuthorized> <NotAuthorized>
@if (!authState.User.Identity.IsAuthenticated) @if (!authState.User.Identity.IsAuthenticated)
@ -16,21 +15,6 @@
} }
</NotAuthorized> </NotAuthorized>
</AuthorizeRouteView> </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...
</div>
</div>
}
</Found> </Found>
<NotFound> <NotFound>
<LayoutView Layout="@typeof(MainLayout)"> <LayoutView Layout="@typeof(MainLayout)">
@ -38,4 +22,18 @@
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </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>
<div class="my-2">
Loading @Status...
</div>
</div>
</LoadingContent>
</CascadingDependencies>
</CascadingAuthenticationState> </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;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MultiShop.Client.Module;
using MultiShop.Shop.Framework; using MultiShop.Shop.Framework;
namespace MultiShop.Client namespace MultiShop.Client.Module
{ {
public partial class App : IDisposable public class ShopModuleLoader
{ {
[Inject] private HttpClient http;
private IHttpClientFactory HttpFactory { get; set; } private ILogger logger;
private string[] moduleNames;
private string[] dependencyNames;
[Inject] public ShopModuleLoader(HttpClient http, ILogger logger)
private ILogger<App> Logger {get; set; }
private bool modulesLoaded = false;
private Dictionary<string, IShop> shops = new Dictionary<string, IShop>();
protected override async Task OnInitializedAsync()
{ {
await base.OnInitializedAsync(); this.http = http;
await DownloadShopModules(); 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[]>(); 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>(); 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) foreach (string moduleName in moduleNames)
{ {
Logger.LogDebug($"Downloading shop: {moduleName}"); logger.LogDebug($"Downloading shop: {moduleName}");
downloadTasks.Add(http.GetByteArrayAsync("shopModule/Modules/" + moduleName), 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) 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); downloadTasks.Add(http.GetByteArrayAsync("ShopModule/Dependencies/" + depName), depName);
} }
@ -55,16 +50,26 @@ namespace MultiShop.Client
{ {
Task<byte[]> downloadTask = await Task.WhenAny(downloadTasks.Keys); Task<byte[]> downloadTask = await Task.WhenAny(downloadTasks.Keys);
assemblyData.Add(downloadTasks[downloadTask], await downloadTask); 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); 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); ShopModuleLoadContext context = new ShopModuleLoadContext(assemblyData);
Logger.LogInformation("Beginning to load shop modules."); logger.LogInformation("Beginning to load shop modules.");
foreach (string moduleName in moduleNames) 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)); Assembly moduleAssembly = context.LoadFromAssemblyName(new AssemblyName(moduleName));
bool shopLoaded = false; bool shopLoaded = false;
foreach (Type type in moduleAssembly.GetTypes()) foreach (Type type in moduleAssembly.GetTypes())
@ -75,32 +80,25 @@ namespace MultiShop.Client
shopLoaded = true; shopLoaded = true;
shop.Initialize(); shop.Initialize();
shops.Add(shop.ShopName, shop); shops.Add(shop.ShopName, shop);
Logger.LogDebug($"Added shop: {shop.ShopName}"); logger.LogDebug($"Added shop: {shop.ShopName}");
} }
} }
} }
if (!shopLoaded) { 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."); logger.LogInformation($"Shop module loading complete. Loaded a total of {shops.Count} shops.");
modulesLoaded = true;
foreach (string assemblyName in context.UseCounter.Keys) foreach (string assemblyName in context.UseCounter.Keys)
{ {
int usage = context.UseCounter[assemblyName]; int usage = context.UseCounter[assemblyName];
Logger.LogDebug($"\"{assemblyName}\" was used {usage} times."); logger.LogDebug($"\"{assemblyName}\" was used {usage} times.");
if (usage <= 0) { 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() return shops;
{
foreach (string name in shops.Keys)
{
shops[name].Dispose();
}
} }
} }
} }

View File

@ -5,7 +5,7 @@
<RemoteAuthenticatorView Action="@Action"> <RemoteAuthenticatorView Action="@Action">
<LoggingIn> <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="my-1">
<div class="spinner-grow text-primary" role="status"> <div class="spinner-grow text-primary" role="status">
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
@ -17,7 +17,7 @@
</div> </div>
</LoggingIn> </LoggingIn>
<Registering> <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="my-1">
<div class="spinner-grow text-primary" role="status"> <div class="spinner-grow text-primary" role="status">
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
@ -28,6 +28,18 @@
</div> </div>
</div> </div>
</Registering> </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> </RemoteAuthenticatorView>
@code{ @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 "/" @page "/"
@* TODO: Add main page content.*@ <div>
<h1>Welcome to MultiShop!</h1> <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;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MultiShop.Client.Shared;
using MultiShop.Client.Extensions; using MultiShop.Client.Extensions;
using MultiShop.Client.Listing; using MultiShop.Client.Listing;
using MultiShop.Client.Services;
using MultiShop.Client.Shared;
using MultiShop.Shared.Models; using MultiShop.Shared.Models;
using MultiShop.Shop.Framework; using MultiShop.Shop.Framework;
@ -16,6 +17,9 @@ namespace MultiShop.Client.Pages
{ {
public partial class Search : IAsyncDisposable public partial class Search : IAsyncDisposable
{ {
[Inject]
private LayoutStateChangeNotifier LayoutStateChangeNotifier { get; set; }
[Inject] [Inject]
private ILogger<Search> Logger { get; set; } private ILogger<Search> Logger { get; set; }
@ -23,10 +27,10 @@ namespace MultiShop.Client.Pages
private HttpClient Http { get; set; } private HttpClient Http { get; set; }
[CascadingParameter] [CascadingParameter]
Task<AuthenticationState> AuthenticationStateTask { get; set; } private Task<AuthenticationState> AuthenticationStateTask { get; set; }
[CascadingParameter(Name = "Shops")] [CascadingParameter(Name = "Shops")]
public Dictionary<string, IShop> Shops { get; set; } public IReadOnlyDictionary<string, IShop> Shops { get; set; }
[Parameter] [Parameter]
public string Query { get; set; } public string Query { get; set; }
@ -43,30 +47,45 @@ namespace MultiShop.Client.Pages
private List<ProductListingInfo> listings = new List<ProductListingInfo>(); private List<ProductListingInfo> listings = new List<ProductListingInfo>();
private int resultsChecked = 0; private int resultsChecked = 0;
protected override void OnInitialized()
{
base.OnInitialized();
LayoutStateChangeNotifier.Notify += UpdateState;
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await base.OnInitializedAsync(); await base.OnInitializedAsync();
AuthenticationState authState = await AuthenticationStateTask; 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."); Logger.LogDebug($"User \"{authState.User.Identity.Name}\" is authenticated. Checking for saved profiles.");
HttpResponseMessage searchProfileResponse = await Http.GetAsync("Profile/Search"); HttpResponseMessage searchProfileResponse = await Http.GetAsync("Profile/Search");
if (searchProfileResponse.IsSuccessStatusCode) { if (searchProfileResponse.IsSuccessStatusCode)
{
activeSearchProfile = await searchProfileResponse.Content.ReadFromJsonAsync<SearchProfile>(); activeSearchProfile = await searchProfileResponse.Content.ReadFromJsonAsync<SearchProfile>();
} else { }
else
{
Logger.LogWarning("Could not load search profile from server. Using default."); Logger.LogWarning("Could not load search profile from server. Using default.");
activeSearchProfile = new SearchProfile(); activeSearchProfile = new SearchProfile();
} }
HttpResponseMessage resultsProfileResponse = await Http.GetAsync("Profile/Results"); HttpResponseMessage resultsProfileResponse = await Http.GetAsync("Profile/Results");
if (resultsProfileResponse.IsSuccessStatusCode) { if (resultsProfileResponse.IsSuccessStatusCode)
{
activeResultsProfile = await resultsProfileResponse.Content.ReadFromJsonAsync<ResultsProfile>(); activeResultsProfile = await resultsProfileResponse.Content.ReadFromJsonAsync<ResultsProfile>();
} else { }
else
{
Logger.LogWarning("Could not load results profile from server. Using default."); Logger.LogWarning("Could not load results profile from server. Using default.");
activeResultsProfile = new ResultsProfile(); activeResultsProfile = new ResultsProfile();
} }
} else { }
else
{
activeSearchProfile = new SearchProfile(); activeSearchProfile = new SearchProfile();
activeResultsProfile = new ResultsProfile(); 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); await base.OnAfterRenderAsync(firstRender);
if (firstRender) { if (firstRender)
{
searchBar.Query = Query; searchBar.Query = Query;
searchBar.Searching = true; searchBar.Searching = true;
await PerformSearch(Query); await PerformSearch(Query);
@ -247,13 +268,21 @@ namespace MultiShop.Client.Pages
StateHasChanged(); StateHasChanged();
} }
private async Task UpdateState() {
await InvokeAsync(() => {
StateHasChanged();
});
}
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
AuthenticationState authState = await AuthenticationStateTask; AuthenticationState authState = await AuthenticationStateTask;
if (authState.User.Identity.IsAuthenticated) { if (authState.User.Identity.IsAuthenticated)
{
await Http.PutAsJsonAsync("Profile/Search", activeSearchProfile); await Http.PutAsJsonAsync("Profile/Search", activeSearchProfile);
await Http.PutAsJsonAsync("Profile/Results", activeResultsProfile); await Http.PutAsJsonAsync("Profile/Results", activeResultsProfile);
} }
LayoutStateChangeNotifier.Notify -= UpdateState;
} }
public class Status public class Status

View File

@ -8,6 +8,9 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using System.Net.Http.Json; using System.Net.Http.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MultiShop.Client.Module;
using MultiShop.Shop.Framework;
using MultiShop.Client.Services;
namespace MultiShop.Client namespace MultiShop.Client
{ {
@ -15,7 +18,9 @@ namespace MultiShop.Client
{ {
public static async Task Main(string[] args) public static async Task Main(string[] args)
{ {
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging"));
builder.RootComponents.Add<App>("#app"); builder.RootComponents.Add<App>("#app");
@ -26,15 +31,13 @@ namespace MultiShop.Client
Action<HttpClient> configureClient = client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); Action<HttpClient> configureClient = client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
builder.Services.AddHttpClient("Public-MultiShop.ServerAPI", configureClient); builder.Services.AddHttpClient("Public-MultiShop.ServerAPI", configureClient);
IReadOnlyDictionary<string, string> webApiConfig = null;
using (HttpClient client = new HttpClient()) using (HttpClient client = new HttpClient())
{ {
configureClient.Invoke(client); configureClient.Invoke(client);
webApiConfig = await client.GetFromJsonAsync<IReadOnlyDictionary<string, string>>("PublicApiSettings"); builder.Configuration.AddInMemoryCollection(await client.GetFromJsonAsync<IReadOnlyDictionary<string, string>>("PublicApiSettings"));
} }
builder.Configuration.AddInMemoryCollection(webApiConfig); builder.Services.AddSingleton<LayoutStateChangeNotifier>();
builder.Services.AddApiAuthorization(); 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 @inherits LayoutComponentBase
@implements IDisposable
@inject LayoutStateChangeNotifier LayoutStateChangeNotifier
<div class="page"> <div class=@ApplicationProfile.GetPageCssClass("page")>
<NavMenu /> <HorizontalSiteNav />
<div class="main"> <div class="content">
<div class="content px-4">
@Body @Body
</div> </div>
</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; display: flex;
flex-direction: column; 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; 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 { .content {
padding-top: 1.5rem; padding-top: 1.5rem;
padding-right: 2rem;
padding-left: 2rem;
}
.bg-dark .card {
background-color: #1F1F1F;
} }
#blazor-error-ui { #blazor-error-ui {

View File

Before

Width:  |  Height:  |  Size: 255 B

After

Width:  |  Height:  |  Size: 255 B

View File

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

View File

@ -1,7 +1,10 @@
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Castle.Core.Logging;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MultiShop.Server.Data; using MultiShop.Server.Data;
using MultiShop.Server.Models; using MultiShop.Server.Models;
using MultiShop.Shared.Models; using MultiShop.Shared.Models;
@ -14,12 +17,14 @@ namespace MultiShop.Server.Controllers
[Route("[controller]")] [Route("[controller]")]
public class ProfileController : ControllerBase public class ProfileController : ControllerBase
{ {
private ILogger<ProfileController> logger;
private UserManager<ApplicationUser> userManager; private UserManager<ApplicationUser> userManager;
private ApplicationDbContext dbContext; private ApplicationDbContext dbContext;
public ProfileController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext) public ProfileController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext, ILogger<ProfileController> logger)
{ {
this.userManager = userManager; this.userManager = userManager;
this.dbContext = dbContext; this.dbContext = dbContext;
this.logger = logger;
} }
[HttpGet] [HttpGet]
@ -36,6 +41,14 @@ namespace MultiShop.Server.Controllers
return Ok(userModel.ResultsProfile); 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] [HttpPut]
[Route("Search")] [Route("Search")]
public async Task<IActionResult> PutSearchProfile(SearchProfile searchProfile) { public async Task<IActionResult> PutSearchProfile(SearchProfile searchProfile) {
@ -52,12 +65,25 @@ namespace MultiShop.Server.Controllers
[Route("Results")] [Route("Results")]
public async Task<IActionResult> PutResultsProfile(ResultsProfile resultsProfile) { public async Task<IActionResult> PutResultsProfile(ResultsProfile resultsProfile) {
ApplicationUser userModel = await userManager.GetUserAsync(User); ApplicationUser userModel = await userManager.GetUserAsync(User);
if (userModel.ResultsProfile.Id != resultsProfile.Id) { if (userModel.ResultsProfile.Id != resultsProfile.Id || userModel.Id != resultsProfile.ApplicationUserId) {
return BadRequest(); return BadRequest();
} }
dbContext.Entry(userModel.ResultsProfile).CurrentValues.SetValues(resultsProfile); dbContext.Entry(userModel.ResultsProfile).CurrentValues.SetValues(resultsProfile);
await userManager.UpdateAsync(userModel); await userManager.UpdateAsync(userModel);
return NoContent(); 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 namespace MultiShop.Server.Data.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20210525224658_InitialCreate")] [Migration("20210531175621_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -317,6 +317,32 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUsers"); 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 => modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -452,6 +478,13 @@ namespace MultiShop.Server.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{ {
b.HasOne("MultiShop.Server.Models.ApplicationUser", null) b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
@ -468,6 +501,9 @@ namespace MultiShop.Server.Data.Migrations
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b => modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{ {
b.Navigation("ApplicationProfile")
.IsRequired();
b.Navigation("ResultsProfile") b.Navigation("ResultsProfile")
.IsRequired(); .IsRequired();

View File

@ -106,6 +106,28 @@ namespace MultiShop.Server.Data.Migrations
onDelete: ReferentialAction.Cascade); 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( migrationBuilder.CreateTable(
name: "AspNetUserClaims", name: "AspNetUserClaims",
columns: table => new columns: table => new
@ -245,6 +267,12 @@ namespace MultiShop.Server.Data.Migrations
onDelete: ReferentialAction.Restrict); onDelete: ReferentialAction.Restrict);
}); });
migrationBuilder.CreateIndex(
name: "IX_ApplicationProfile_ApplicationUserId",
table: "ApplicationProfile",
column: "ApplicationUserId",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId", name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims", table: "AspNetRoleClaims",
@ -323,6 +351,9 @@ namespace MultiShop.Server.Data.Migrations
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable(
name: "ApplicationProfile");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "AspNetRoleClaims"); name: "AspNetRoleClaims");

View File

@ -315,6 +315,32 @@ namespace MultiShop.Server.Data.Migrations
b.ToTable("AspNetUsers"); 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 => modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -450,6 +476,13 @@ namespace MultiShop.Server.Data.Migrations
.IsRequired(); .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 => modelBuilder.Entity("MultiShop.Shared.Models.ResultsProfile", b =>
{ {
b.HasOne("MultiShop.Server.Models.ApplicationUser", null) b.HasOne("MultiShop.Server.Models.ApplicationUser", null)
@ -466,6 +499,9 @@ namespace MultiShop.Server.Data.Migrations
modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b => modelBuilder.Entity("MultiShop.Server.Models.ApplicationUser", b =>
{ {
b.Navigation("ApplicationProfile")
.IsRequired();
b.Navigation("ResultsProfile") b.Navigation("ResultsProfile")
.IsRequired(); .IsRequired();

View File

@ -11,5 +11,8 @@ namespace MultiShop.Server.Models
[Required] [Required]
public virtual ResultsProfile ResultsProfile { get; private set; } = new ResultsProfile(); 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; if (EnableMaxShippingFee) _maxShippingFee = value;
} }
} }
public bool KeepUnknownShipping { get; set; } public bool KeepUnknownShipping { get; set; } = true;
[Required] [Required]
public ShopToggler ShopStates { get; set; } = new ShopToggler(); public ShopToggler ShopStates { get; set; } = new ShopToggler();
@ -115,5 +115,11 @@ namespace MultiShop.Shared.Models
{ {
return Id; return Id;
} }
public SearchProfile DeepCopy() {
SearchProfile profile = (SearchProfile)MemberwiseClone();
profile.ShopStates = ShopStates.Clone();
return profile;
}
} }
} }