Basic search and sorting complete.

This commit is contained in:
2021-05-09 01:49:37 -05:00
parent 5d3a74a89e
commit 9dc8917aa5
39 changed files with 1482 additions and 622 deletions

View File

@@ -1,28 +0,0 @@
using System;
using System.Reflection;
using System.Runtime.Loader;
// from https://docs.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support#load-plugins
namespace MultiShop.Load
{
public class LoaderContext : AssemblyLoadContext
{
private AssemblyDependencyResolver resolver;
public LoaderContext(string path)
{
resolver = new AssemblyDependencyResolver(path);
}
protected override Assembly Load(AssemblyName assemblyName)
{
string path = resolver.ResolveAssemblyToPath(assemblyName);
return path == null ? LoadFromAssemblyPath(path) : null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
string path = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
return path == null ? LoadUnmanagedDllFromPath(path) : IntPtr.Zero;
}
}
}

View File

@@ -1,41 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using MultiShop.ShopFramework;
namespace MultiShop.Load
{
public class ShopLoader
{
public IEnumerable<IShop> LoadShops(string shop) {
return InstantiateShops(LoadAssembly(shop));
}
public IReadOnlyDictionary<string, IEnumerable<IShop>> LoadAllShops(IEnumerable<string> directories) {
Dictionary<string, IEnumerable<IShop>> res = new Dictionary<string, IEnumerable<IShop>>();
foreach (string dir in directories)
{
res.Add(dir, LoadShops(dir));
}
return res;
}
private IEnumerable<IShop> InstantiateShops(Assembly assembly) {
foreach (Type type in assembly.GetTypes())
{
if (typeof(IShop).IsAssignableFrom(type)) {
IShop shop = Activator.CreateInstance(type) as IShop;
if (shop != null) {
yield return shop;
}
}
}
}
private Assembly LoadAssembly(string path) {
LoaderContext context = new LoaderContext(path);
return context.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(path)));
}
}
}

View File

@@ -1,16 +0,0 @@
@page "/counter"
<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View File

@@ -1,55 +0,0 @@
@page "/fetchdata"
@inject HttpClient Http
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[] forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
}
public class WeatherForecast
{
public DateTime Date { get; set; }
public int TemperatureC { get; set; }
public string Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -1,7 +1,3 @@
@page "/"
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<h1>Welcome to MultiShop!</h1>

View File

@@ -0,0 +1,11 @@
@page "/info"
@using ShopFramework
<div>
</div>
@code {
[CascadingParameter(Name = "Shops")]
public Dictionary<string, IShop> Shops { get; set; }
}

View File

@@ -0,0 +1,397 @@
@page "/search/{Query?}"
@using Microsoft.Extensions.Configuration
@using ShopFramework
@using SimpleLogger
@using SearchStructures
@inject HttpClient Http
@inject IConfiguration Configuration
@inject IJSRuntime js
@* TODO: Finish sorting, move things to individual components where possible, key search results. *@
<div class="my-2">
<div class="input-group my-2">
<input type="text" class="form-control" placeholder="What are you looking for?" aria-label="What are you looking for?" id="search-input" @bind="Query" @onkeyup="@(async (a) => {if (a.Code == "Enter" || a.Code == "NumpadEnter") await PerformSearch(Query);})" disabled="@searching">
<div class="input-group-append">
<button class=@ToggleSearchConfigButtonCss type="button" @onclick="@(() => showSearchConfiguration = !showSearchConfiguration)" title="Configure"><span class="oi oi-cog align-text-top"></span></button>
<button class="btn btn-outline-primary" type="button" @onclick="@(async () => await PerformSearch(Query))" disabled="@searching">Search</button>
</div>
</div>
@if (showSearchConfiguration)
{
<div class="mb-2 mt-4 py-2">
<h4>Configuration</h4>
<div class="d-flex flex-wrap justify-content-start">
<div class="card m-2" style="width: 24em;">
<div class="card-body">
<h5>Shop Quantity</h5>
<h6 class="card-subtitle mb-2 text-muted">How many results from each store?</h6>
<p class="card-text">This is the maximum number of results we gather for each store we have access to. The larger the result, the longer it takes to load search queries.</p>
<div class="form-group">
<label for="quantitySlider">Quantity: @activeProfile.maxResults</label>
<input class="form-control-range" type="range" id="quantitySlider" min="1" max="200" step="1" @bind="activeProfile.maxResults" @bind:event="oninput">
</div>
</div>
</div>
<div class="card m-2" style="width: 18em;">
<div class="card-body">
<h5 class="card-title">Currency</h5>
<h6 class="card-subtitle mb-2 text-muted">What currency would you like results in?</h6>
<p class="card-text">The currency displayed may either be from the online store directly, or through currency conversion (we'll put a little tag beside the coonverted ones).</p>
<div class="input-group my-3">
<div class="input-group-prepend">
<label class="input-group-text" for="currency-select">Currency</label>
</div>
<select class="form-control custom-select" id="currency-select" @bind="activeProfile.currency">
@foreach (Currency currency in Enum.GetValues<Currency>())
{
@if (currency == activeProfile.currency)
{
<option selected>@currency</option>
}
else
{
<option value="@currency">@currency</option>
}
}
</select>
</div>
</div>
</div>
<div class="card m-2" style="width: 23em;">
<div class="card-body">
<h5>Minimum Rating</h5>
<h6 class="card-subtitle mb-2 text-muted">We'll crop out the lower rated stuff.</h6>
<p class="card-text">We'll only show products that have a rating greater than or equal to the set minimum rating. Optionally, we can also show those that don't have rating.</p>
<div class="form-group">
<label for="ratingSlider">Minimum rating: @(string.Format("{0:P0}", activeProfile.minRating))</label>
<input class="form-control-range" type="range" id="ratingSlider" min="0" max="1" step="0.01" @bind="activeProfile.minRating" @bind:event="oninput">
</div>
<div class="form-group form-check">
<input class="form-check-input" type="checkbox" id="keepUnratedCheckbox" @bind="activeProfile.keepUnrated">
<label class="form-check-label" for="keepUnratedCheckbox">Keep unrated results</label>
</div>
</div>
</div>
<div class="card m-2" style="width: 25em;">
<div class="card-body">
<h5>Price Range</h5>
<h6 class="card-subtitle mb-2 text-muted">Whats your budget?</h6>
<p class="card-text">Results will be pruned of budgets that fall outside of the designated range. The checkbox can enable or disable the upper bound. These bounds do include the shipping price if it's known.</p>
<div class="input-group my-2">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="checkbox" @bind="activeProfile.enableUpperPrice">
</div>
<span class="input-group-text">Upper limit</span>
</div>
<input type="number" class="form-control" @bind="activeProfile.UpperPrice" disabled="@(!activeProfile.enableUpperPrice)">
<div class="input-group-append">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="input-group my-2">
<div class="input-group-prepend">
<span class="input-group-text">Lower limit</span>
</div>
<input type="number" class="form-control" @bind="activeProfile.lowerPrice">
<div class="input-group-prepend">
<span class="input-group-text">.00</span>
</div>
</div>
</div>
</div>
<div class="card m-2" style="width: 22em;">
<div class="card-body">
<h5>Shops Searched</h5>
<h6 class="card-subtitle mb-2 text-muted">What's your preference?</h6>
<p class="card-text">We'll only look through shops that are enabled in this list. Of course, at least one shop has to be enabled.</p>
@foreach (string shop in Shops.Keys)
{
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id=@(shop + "Checkbox") @bind="activeProfile.shopStates[shop]" disabled="@(!activeProfile.shopStates.CanDisableShop())">
<label class="form-check-label" for=@(shop + "Checkbox")>@shop enabled</label>
</div>
}
</div>
</div>
<div class="card m-2" style="width: 20em;">
<div class="card-body">
<h5>Minimum Purchases</h5>
<h6 class="card-subtitle mb-2 text-muted">If they're purchasing, I am too!</h6>
<p class="card-text">Only products that have enough purchases are shown. Optionally, we can also show results that don't have a purchase tally.</p>
<div class="input-group my-2">
<div class="input-group-prepend">
<span class="input-group-text">Minimum purchases</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minPurchases">
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullPurchasesCheckbox" @bind="activeProfile.keepUnknownPurchaseCount">
<label class="form-check-label" for="keepNullPurchasesCheckbox">Keep unknown listings</label>
</div>
</div>
</div>
<div class="card m-2" style="width: 20em;">
<div class="card-body">
<h5>Minimum Reviews</h5>
<h6 class="card-subtitle mb-2 text-muted">Well if this many people say it's good...</h6>
<p class="card-text">Only products with enough reviews/ratings are shown. Optionally, we can also show the results that don't have this information.</p>
<div class="input-group my-2">
<div class="input-group-prepend">
<span class="input-group-text">Minimum reviews</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.minReviews">
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullRatingsCheckbox" @bind="activeProfile.keepUnknownRatingCount">
<label class="form-check-label" for="keepNullRatingsCheckbox">Keep unknown listings</label>
</div>
</div>
</div>
<div class="card m-2" style="width: 22rem;">
<div class="card-body">
<h5>Shipping</h5>
<h6 class="card-subtitle mb-2 text-muted">Free shipping?</h6>
<p class="card-text">Show results with shipping rates less than a certain value, and choose whether or not to display listings without shipping information.</p>
<div class="input-group my-2">
<div class="input-group-prepend">
<span class="input-group-text">
<input type="checkbox" @bind="activeProfile.enableMaxShippingFee">
</span>
<span class="input-group-text">Max shipping</span>
</div>
<input type="number" class="form-control" min="0" step="1" @bind="activeProfile.MaxShippingFee" disabled="@(!activeProfile.enableMaxShippingFee)">
<div class="input-group-append">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="form-group form-check my-2">
<input class="form-check-input" type="checkbox" id="keepNullShipping" @bind="activeProfile.keepUnknownShipping">
<label class="form-check-label" for="keepNullShipping">Keep unknown listings</label>
</div>
</div>
</div>
</div>
</div>
}
</div>
<div class="my-3 py-2">
<div class="d-flex flex-wrap justify-content-between" style="width: 100%; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: lightgray;">
<div class="align-self-end">
@if (searching)
{
@if (listings.Count != 0)
{
<div class="spinner-border spinner-border-sm text-secondary my-auto mr-1" role="status">
<span class="sr-only">Loading...</span>
</div>
<span class="text-muted mx-1">Looked through @resultsChecked listings and found @listings.Count viable results. We're still looking!</span>
}
else
{
<div class="spinner-border spinner-border-sm text-primary my-auto mr-1" role="status">
<span class="sr-only">Loading...</span>
</div>
<span class="text-muted">Hold tight! We're looking through the stores for viable results...</span>
}
}
else if (listings.Count != 0)
{
@if (organizing)
{
<div class="spinner-border spinner-border-sm text-success my-auto mr-1" role="status">
<span class="sr-only">Loading...</span>
</div>
<span class="text-muted">Organizing the data to your spec...</span>
}
else
{
<span class="text-muted">Looked through @resultsChecked listings and found @listings.Count viable results.</span>
}
}
else if (searched)
{
<span class="text-muted">We've found @resultsChecked listings and unfortunately none matched your search.</span>
}
else
{
<span class="text-muted">Search for something to see the results!</span>
}
</div>
<CustomDropdown AdditionalButtonClasses="btn-outline-secondary btn-tab" Justify="right">
<ButtonContent>
<span class="oi oi-sort-descending"></span>
</ButtonContent>
<DropdownContent>
<DragAndDropList Items="@(activeResultsProfile.Order)" Context="item" AdditionalListClasses="list-group-empty-top-left" OnOrderChange="@(async () => await Organize(activeResultsProfile.Order))">
<DraggableItem>
@(item.FriendlyName())
</DraggableItem>
</DragAndDropList>
</DropdownContent>
</CustomDropdown>
</div>
<div>
<ListingTableView Products="@listings" />
</div>
</div>
@code {
[CascadingParameter(Name = "Shops")]
public Dictionary<string, IShop> Shops { get; set; }
[Parameter]
public string Query { get; set; }
private SearchProfile activeProfile = new SearchProfile();
private ResultsProfile activeResultsProfile = new ResultsProfile();
private bool showSearchConfiguration = false;
private string ToggleSearchConfigButtonCss
{
get => "btn btn-outline-secondary" + (showSearchConfiguration ? " active" : "");
}
private bool searched = false;
private bool searching = false;
private bool organizing = false;
private int resultsChecked = 0;
private List<ProductListingInfo> listings = new List<ProductListingInfo>();
protected override void OnInitialized()
{
foreach (string shop in Shops.Keys)
{
activeProfile.shopStates[shop] = true;
}
base.OnInitialized();
}
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
if (!string.IsNullOrEmpty(Query))
{
await PerformSearch(Query);
}
await base.OnParametersSetAsync();
}
private async Task PerformSearch(string query)
{
if (string.IsNullOrWhiteSpace(query)) return;
if (searching) return;
searching = true;
Logger.Log($"Received search request for \"{query}\".", LogLevel.Debug);
resultsChecked = 0;
listings.Clear();
Dictionary<ResultsProfile.Category, List<ProductListingInfo>> greatest = new Dictionary<ResultsProfile.Category,
List<ProductListingInfo>>();
foreach (string shopName in Shops.Keys)
{
if (activeProfile.shopStates[shopName])
{
Logger.Log($"Querying \"{shopName}\" for products.");
Shops[shopName].SetupSession(query, activeProfile.currency);
int shopViableResults = 0;
await foreach (ProductListing listing in Shops[shopName])
{
resultsChecked += 1;
if (resultsChecked % 50 == 0) {
StateHasChanged();
await Task.Yield();
}
if (listing.Shipping == null && !activeProfile.keepUnknownShipping || (activeProfile.enableMaxShippingFee && listing.Shipping > activeProfile.MaxShippingFee)) continue;
float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0;
if (!(listing.LowerPrice + shippingDifference >= activeProfile.lowerPrice && (!activeProfile.enableUpperPrice || listing.UpperPrice + shippingDifference <= activeProfile.UpperPrice))) continue;
if ((listing.Rating == null && !activeProfile.keepUnrated) || activeProfile.minRating > (listing.Rating == null ? 0 : listing.Rating)) continue;
if ((listing.PurchaseCount == null && !activeProfile.keepUnknownPurchaseCount) || activeProfile.minPurchases > (listing.PurchaseCount == null ? 0 : listing.PurchaseCount)) continue;
if ((listing.ReviewCount == null && !activeProfile.keepUnknownRatingCount) || activeProfile.minReviews > (listing.ReviewCount == null ? 0 : listing.ReviewCount)) continue;
ProductListingInfo info = new ProductListingInfo(listing, shopName);
listings.Add(info);
await Task.Yield();
foreach (ResultsProfile.Category c in Enum.GetValues<ResultsProfile.Category>())
{
if (!greatest.ContainsKey(c)) greatest[c] = new List<ProductListingInfo>();
if (greatest[c].Count > 0)
{
int? compResult = c.CompareListings(info, greatest[c][0]);
if (compResult.HasValue)
{
if (compResult > 0) greatest[c].Clear();
if (compResult >= 0) greatest[c].Add(info);
}
}
else
{
if (c.CompareListings(info, info).HasValue)
{
greatest[c].Add(info);
}
}
}
shopViableResults += 1;
if (shopViableResults >= activeProfile.maxResults) break;
}
Logger.Log($"\"{shopName}\" has completed. There are {listings.Count} results in total.", LogLevel.Debug);
}
else
{
Logger.Log($"Skipping {shopName} since it's disabled.");
}
}
searching = false;
searched = true;
foreach (ResultsProfile.Category c in greatest.Keys)
{
foreach (ProductListingInfo info in greatest[c])
{
info.Tops.Add(c);
}
}
await Organize(activeResultsProfile.Order);
}
private async Task Organize(List<ResultsProfile.Category> order)
{
if (searching) return;
organizing = true;
StateHasChanged();
List<ProductListingInfo> sortedResults = await Task.Run<List<ProductListingInfo>>(() => {
List<ProductListingInfo> sorted = new List<ProductListingInfo>(listings);
sorted.Sort((a, b) =>
{
foreach (ResultsProfile.Category category in activeResultsProfile.Order)
{
int? compareResult = category.CompareListings(a, b);
if (compareResult.HasValue && compareResult.Value != 0)
{
return -compareResult.Value;
}
}
return 0;
});
return sorted;
});
listings.Clear();
listings.AddRange(sortedResults);
organizing = false;
StateHasChanged();
}
}

View File

@@ -1,12 +1,8 @@
using System;
using System.Net.Http;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Text;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SimpleLogger;
namespace MultiShop
@@ -15,12 +11,11 @@ namespace MultiShop
{
public static async Task Main(string[] args)
{
Logger.AddLogListener(new ConsoleLogReceiver());
Logger.AddLogListener(new ConsoleLogReceiver() {Level = LogLevel.Debug});
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using MultiShop.ShopFramework;
namespace MultiShop.SearchStructures
{
public class ProductListingInfo
{
public ProductListing Listing { get; private set; }
public string ShopName { get; private set; }
public float RatingToPriceRatio {
get {
int reviewFactor = Listing.ReviewCount.HasValue ? Listing.ReviewCount.Value : 1;
int purchaseFactor = Listing.PurchaseCount.HasValue ? Listing.PurchaseCount.Value : 1;
float ratingFactor = 1 + (Listing.Rating.HasValue ? Listing.Rating.Value : 0);
return (ratingFactor * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor))/(Listing.LowerPrice * Listing.UpperPrice);
}
}
public ISet<ResultsProfile.Category> Tops { get; private set; } = new HashSet<ResultsProfile.Category>();
public ProductListingInfo(ProductListing listing, string shopName)
{
this.Listing = listing;
this.ShopName = shopName;
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
using System.ComponentModel;
using MultiShop.ShopFramework;
namespace MultiShop.SearchStructures
{
public static class ResultCategoryExtensions
{
public static int? CompareListings(this ResultsProfile.Category category, ProductListingInfo a, ProductListingInfo b)
{
switch (category)
{
case ResultsProfile.Category.RatingPriceRatio:
float dealDiff = a.RatingToPriceRatio - b.RatingToPriceRatio;
int dealCeil = (int)Math.Ceiling(Math.Abs(dealDiff));
return dealDiff < 0 ? -dealCeil : dealCeil;
case ResultsProfile.Category.Price:
float priceDiff = b.Listing.UpperPrice - a.Listing.UpperPrice;
int priceCeil = (int)Math.Ceiling(Math.Abs(priceDiff));
return priceDiff < 0 ? -priceCeil : priceCeil;
case ResultsProfile.Category.Purchases:
return a.Listing.PurchaseCount - b.Listing.PurchaseCount;
case ResultsProfile.Category.Reviews:
return a.Listing.ReviewCount - b.Listing.ReviewCount;
}
throw new ArgumentException($"{category} does not have a defined comparison.");
}
public static string FriendlyName(this ResultsProfile.Category category)
{
switch (category)
{
case ResultsProfile.Category.RatingPriceRatio:
return "Best rating to price ratio first";
case ResultsProfile.Category.Price:
return "Lowest price first";
case ResultsProfile.Category.Purchases:
return "Most purchases first";
case ResultsProfile.Category.Reviews:
return "Most reviews first";
}
throw new ArgumentException($"{category} does not have a friendly name defined.");
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MultiShop.SearchStructures
{
public class ResultsProfile
{
public List<Category> Order { get; private set; } = new List<Category>(Enum.GetValues<Category>().Length);
public ResultsProfile()
{
foreach (Category category in Enum.GetValues<Category>())
{
Order.Add(category);
}
}
public Category GetCategory(int position)
{
return Order[position];
}
public enum Category
{
RatingPriceRatio,
Price,
Purchases,
Reviews,
}
}
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Generic;
using MultiShop.ShopFramework;
namespace MultiShop.SearchStructures
{
public class SearchProfile
{
public Currency currency;
public int maxResults;
public float minRating;
public bool keepUnrated;
public bool enableUpperPrice;
private int upperPrice;
public int UpperPrice
{
get
{
return upperPrice;
}
set
{
if (enableUpperPrice) upperPrice = value;
}
}
public int lowerPrice;
public int minPurchases;
public bool keepUnknownPurchaseCount;
public int minReviews;
public bool keepUnknownRatingCount;
public bool enableMaxShippingFee;
private int maxShippingFee;
public int MaxShippingFee {
get {
return maxShippingFee;
}
set {
if (enableMaxShippingFee) maxShippingFee = value;
}
}
public bool keepUnknownShipping;
public ShopStateTracker shopStates = new ShopStateTracker();
public SearchProfile()
{
currency = Currency.CAD;
maxResults = 100;
minRating = 0.8f;
keepUnrated = true;
enableUpperPrice = false;
upperPrice = 0;
lowerPrice = 0;
minPurchases = 0;
keepUnknownPurchaseCount = true;
minReviews = 0;
keepUnknownRatingCount = true;
enableMaxShippingFee = false;
maxShippingFee = 0;
keepUnknownShipping = true;
}
public class ShopStateTracker
{
private HashSet<string> shopsEnabled = new HashSet<string>();
public bool this[string name]
{
get
{
return shopsEnabled.Contains(name);
}
set
{
if (value == false && !CanDisableShop()) return;
if (value)
{
shopsEnabled.Add(name);
}
else
{
shopsEnabled.Remove(name);
}
}
}
public bool CanDisableShop() {
return shopsEnabled.Count > 1;
}
}
}
}

View File

@@ -0,0 +1,45 @@
@inject IJSRuntime JS
<div style="position: relative;" @ref="dropdown">
<button type="button" class=@ButtonCss>@ButtonContent</button>
<div class="invisible" style="position: absolute;" tabindex="0">
@DropdownContent
</div>
</div>
@code {
[Parameter]
public RenderFragment ButtonContent { get; set; }
[Parameter]
public RenderFragment DropdownContent { get; set; }
[Parameter]
public string AdditionalButtonClasses { get; set; }
[Parameter]
public string Justify { get; set; }
private ElementReference dropdown;
private string ButtonCss
{
get => "btn " + AdditionalButtonClasses;
}
protected override async Task OnParametersSetAsync()
{
AdditionalButtonClasses = AdditionalButtonClasses ?? "";
Justify = Justify ?? "center";
await base.OnParametersSetAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("customDropdown", dropdown, Justify);
}
await base.OnAfterRenderAsync(firstRender);
}
}

View File

@@ -0,0 +1,65 @@
@using SimpleLogger
@typeparam TItem
@inject IJSRuntime JS
<ul class=@ListGroupCss style="width: max-content;" @ref="dragAndDrop">
@foreach (TItem item in Items)
{
<li class="list-group-item list-group-item-hover" draggable="true" @ondragstart="@(() => itemDraggedIndex = Items.IndexOf(item))" @ondrop="@(async () => await OnDrop(item))">
<span class="mx-1 oi oi-elevator"></span>
@DraggableItem(item)
</li>
}
</ul>
@code {
[Parameter]
public List<TItem> Items { get; set; }
[Parameter]
public string AdditionalListClasses { get; set; }
[Parameter]
public EventCallback OnOrderChange { get; set; }
[Parameter]
public RenderFragment<TItem> DraggableItem { get; set; }
private ElementReference dragAndDrop;
private int itemDraggedIndex = -1;
private string ListGroupCss
{
get => "list-group " + AdditionalListClasses;
}
protected override async Task OnParametersSetAsync()
{
AdditionalListClasses = AdditionalListClasses ?? "";
await base.OnParametersSetAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("dragAndDropList", dragAndDrop);
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task OnDrop(TItem dropped)
{
TItem item = Items[itemDraggedIndex];
if (item.Equals(dropped)) return;
int indexOfDrop = Items.IndexOf(dropped);
Items.RemoveAt(itemDraggedIndex);
Items.Insert(indexOfDrop, item);
itemDraggedIndex = -1;
await OnOrderChange.InvokeAsync();
}
}

View File

@@ -0,0 +1,99 @@
@using ShopFramework
@using SearchStructures
<div class="table-responsive">
<table class="table table-top-borderless">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Price</th>
<th scope="col">Shipping</th>
<th scope="col">Purchases</th>
<th scope="col">Rating</th>
<th scope="col">Reviews</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<Virtualize Items="@Products" Context="product">
<tr>
<th scope="row" @key="product.Listing">
<div class="text-truncate">@product.Listing.Name</div>
<small>From @product.ShopName</small>
@if (product.Listing.ConvertedPrices)
{
<span class="ml-3 mr-1 badge badge-warning">Converted price</span>
}
@foreach (ResultsProfile.Category c in product.Tops)
{
<span class="mx-1 badge badge-primary">@CategoryTags(c)</span>
}
</th>
<td>
@if (product.Listing.UpperPrice != product.Listing.LowerPrice)
{
<div class="text-truncate">
@product.Listing.LowerPrice to @product.Listing.UpperPrice
</div>
}
else
{
<div class="text-truncate">
@GetOrNA(product.Listing.LowerPrice)
</div>
}
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.Shipping)
</div>
</td>
<td>
<div class="text-truncate">
@GetOrNA(product.Listing.PurchaseCount)
</div>
</td>
<td>
<div class="text-truncate">
@(product.Listing.Rating != null ? string.Format("{0:P2}", product.Listing.Rating) : "N/A")
</div>
</td>
<td>@GetOrNA(product.Listing.ReviewCount)</td>
<td>
<a href="@product.Listing.URL" class="btn btn-primary" target="_blank">View</a>
</td>
</tr>
</Virtualize>
</tbody>
</table>
</div>
@code {
[Parameter]
public List<ProductListingInfo> Products { get; set; }
private string PriceCellHeight { get => "height: " + "4rem"; }
private string GetOrNA(object data, string prepend = null, string append = null)
{
return data != null ? (prepend + data.ToString() + append) : "N/A";
}
private string CategoryTags(ResultsProfile.Category c)
{
switch (c)
{
case ResultsProfile.Category.RatingPriceRatio:
return "Best rating to price ratio";
case ResultsProfile.Category.Price:
return "Lowest price";
case ResultsProfile.Category.Purchases:
return "Most purchases";
case ResultsProfile.Category.Reviews:
return "Most reviews";
}
throw new ArgumentException($"{c} does not have an associated string.");
}
}

View File

@@ -0,0 +1,3 @@
tbody > tr > th > div {
width: 45em;
}

View File

@@ -1,17 +1,82 @@
@using ShopFramework
@using SimpleLogger
@using System.Reflection
@using Microsoft.Extensions.Configuration
@inject HttpClient Http
@inject IConfiguration Configuration
@inherits LayoutComponentBase
@implements IDisposable
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<NavMenu />
<div class="main">
<div class="top-row px-4">
<a href="http://blazor.net" target="_blank" class="ml-md-auto">About</a>
</div>
<div class="content px-4">
@Body
@if (modulesLoaded)
{
<CascadingValue Value="shops" Name="Shops">
@Body
</CascadingValue>
}
</div>
</div>
</div>
@code {
private bool modulesLoaded = false;
private Dictionary<string, IShop> shops = new Dictionary<string, IShop>();
protected override async Task OnInitializedAsync()
{
await DownloadShopModules();
await base.OnInitializedAsync();
}
private async Task DownloadShopModules() {
Logger.Log($"Fetching shop modules.", LogLevel.Debug);
string[] shopNames = await Http.GetFromJsonAsync<string[]>(Configuration["ModulesList"]);
Task<byte[]>[] assemblyDownloadTasks = new Task<byte[]>[shopNames.Length];
for (int i = 0; i < shopNames.Length; i++)
{
string shopPath = Configuration["ModulesDir"] + shopNames[i] + ".dll";
assemblyDownloadTasks[i] = Http.GetByteArrayAsync(shopPath);
Logger.Log($"Downloading \"{shopPath}\".", LogLevel.Debug);
}
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyDependencyRequest;
foreach (Task<byte[]> task in assemblyDownloadTasks)
{
Assembly assembly = AppDomain.CurrentDomain.Load(await task);
foreach (Type type in assembly.GetTypes())
{
if (typeof(IShop).IsAssignableFrom(type)) {
IShop shop = Activator.CreateInstance(type) as IShop;
if (shop != null) {
shop.Initialize();
shops.Add(shop.ShopName, shop);
Logger.Log($"Registered and started lifetime of module for \"{shop.ShopName}\".", LogLevel.Debug);
}
}
}
}
modulesLoaded = true;
}
private Assembly OnAssemblyDependencyRequest(object sender, ResolveEventArgs args) {
Logger.Log($"Assembly {args.RequestingAssembly} is requesting dependency assembly {args.Name}. Attempting to retrieve...", LogLevel.Debug);
return AppDomain.CurrentDomain.Load(Http.GetByteArrayAsync(Configuration["ModulesDir"] + args.Name + ".dll").Result);
}
public void Dispose() {
foreach (string name in shops.Keys)
{
shops[name].Dispose();
Logger.Log($"Ending lifetime of shop module for \"{name}\".");
}
}
}

View File

@@ -8,28 +8,6 @@
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
}
.top-row a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row:not(.auth) {
@@ -39,32 +17,4 @@
.top-row.auth {
justify-content: space-between;
}
.top-row a, .top-row .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.main > div {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
}

View File

@@ -1,34 +1,32 @@
<div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="">MultiShop</a>
<button class="navbar-toggler" @onclick="ToggleNavMenu">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="">
<img src="100.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>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<ul class="nav flex-column">
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</li>
</ul>
</div>
<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>
</div>
</nav>
@code {
private bool collapseNavMenu = true;
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private string NavMenuCssClass => (collapseNavMenu ? "collapse " : " ") + "navbar-collapse";
private void ToggleNavMenu()
{

View File

@@ -1,62 +0,0 @@
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
.top-row {
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.oi {
width: 2rem;
font-size: 1.1rem;
vertical-align: text-top;
top: -2px;
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.25);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.collapse {
/* Never collapse the sidebar for wide screens */
display: block;
}
}

View File

@@ -1,16 +0,0 @@
<div class="alert alert-secondary mt-4" role="alert">
<span class="oi oi-pencil mr-2" aria-hidden="true"></span>
<strong>@Title</strong>
<span class="text-nowrap">
Please take our
<a target="_blank" class="font-weight-bold" href="https://go.microsoft.com/fwlink/?linkid=2137916">brief survey</a>
</span>
and tell us what you think.
</div>
@code {
// Demonstrates how a parent component can supply parameters
[Parameter]
public string Title { get; set; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

View File

@@ -1,4 +1,5 @@
{
"LogLevel" : "DEBUG",
"ModulesList" : "modules/modules_content"
"LogLevel" : "Debug",
"ModulesDir" : "modules/",
"ModulesList" : "modules/modules_content.json"
}

View File

@@ -4,30 +4,35 @@ html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #0366d6;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.content {
padding-top: 1.1rem;
padding-top: 1.5rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
.table.table-top-borderless thead th {
border-top-style: none;
}
.invalid {
outline: 1px solid red;
.btn.btn-tab {
border-bottom-style: none;
border-bottom-left-radius: 0em;
border-bottom-right-radius: 0em;
}
.validation-message {
color: red;
.list-group-top-square-left .list-group-item:first-child {
border-top-left-radius: 0em;
}
.list-group-top-square-right .list-group-item:first-child {
border-top-right-radius: 0em;
}
li.list-group-item.list-group-item-hover:hover {
background-color: #F5F5F5;
}
li.list-group-nospacing {
padding: 0px;
margin: 0px;
}
#blazor-error-ui {

View File

@@ -19,7 +19,8 @@
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="js/ComponentsSupport.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
</html>

View File

@@ -0,0 +1,69 @@
function customDropdown(elem, justify) {
let btn = elem.querySelector("button");
let dropdown = elem.querySelector("div");
if (justify.toLowerCase() == "left") {
dropdown.style.left = "0px";
} else if (justify.toLowerCase() == "center") {
dropdown.style.left = "50%";
} else if (justify.toLowerCase() == "right") {
dropdown.style.right = "0px";
}
let openFunc = () => {
btn.classList.add("active");
dropdown.classList.remove("invisible");
dropdown.focus();
}
let closeFunc = () => {
btn.classList.remove("active");
dropdown.classList.add("invisible");
}
btn.addEventListener("click", () => {
if (!btn.classList.contains("active")) {
openFunc();
} else {
closeFunc();
}
});
dropdown.addEventListener("focusout", (e) => {
if (e.relatedTarget != btn) {
closeFunc();
}
});
dropdown.addEventListener("keyup", (e) => {
if (e.code == "Escape") {
dropdown.blur();
}
});
}
function dragAndDropList(elem) {
elem.addEventListener("dragover", (e) => {
e.preventDefault();
});
let itemDragged;
for (let i = 0; i < elem.childElementCount; i++) {
let e = elem.children[i];
e.addEventListener("dragstart", () => {
itemDragged = e;
e.classList.add("list-group-item-secondary");
e.classList.remove("list-group-item-hover");
});
e.addEventListener("dragenter", () => {
e.classList.add("list-group-item-primary");
e.classList.remove("list-group-item-hover");
});
e.addEventListener("dragleave", () => {
e.classList.remove("list-group-item-primary");
e.classList.add("list-group-item-hover");
});
e.addEventListener("drop", () => {
e.classList.add("list-group-item-hover");
e.classList.remove("list-group-item-primary");
itemDragged.classList.remove("list-group-item-secondary");
itemDragged.classList.add("list-group-item-hover");
});
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -4,9 +4,10 @@ import json
modulepaths = []
for content in os.listdir(os.getcwd()):
if (os.path.isfile(content) and os.path.splitext(content)[1] == ".dll"):
print("Adding \"{0}\" to list of modules.".format(content))
modulepaths.append(content)
components = os.path.splitext(content)
if (os.path.isfile(content) and components[1] == ".dll"):
print("Adding \"{0}\" to list of modules.".format(components[0]))
modulepaths.append(components[0])
file = open("modules_content.json", "w")
json.dump(modulepaths, file, sort_keys=True, indent=4)

View File

@@ -0,0 +1,3 @@
[
"AliExpressShop"
]

View File

@@ -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"
}
]