Added primitive search mechanism in backend.

Began implementing search mechanism for frontend.
This commit is contained in:
Harrison Deng 2021-08-05 01:22:19 -05:00
parent f71758ca69
commit c94ea4a624
56 changed files with 1623 additions and 490 deletions

View File

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api; using Props.Shop.Adafruit.Api;
using Props.Shop.Framework; using Props.Shop.Framework;
@ -9,7 +11,7 @@ namespace Props.Shop.Adafruit
{ {
public class AdafruitShop : IShop public class AdafruitShop : IShop
{ {
private ProductListingManager productListingManager; private SearchManager searchManager;
private Configuration configuration; private Configuration configuration;
private HttpClient http; private HttpClient http;
private bool disposedValue; private bool disposedValue;
@ -27,24 +29,24 @@ namespace Props.Shop.Adafruit
false, false,
true true
); );
public void Initialize(string workspaceDir)
public byte[] GetDataForPersistence()
{
return JsonSerializer.SerializeToUtf8Bytes(configuration);
}
public IEnumerable<IOption> Initialize(byte[] data)
{ {
http = new HttpClient(); http = new HttpClient();
http.BaseAddress = new Uri("http://www.adafruit.com/api"); http.BaseAddress = new Uri("http://www.adafruit.com/api/");
configuration = JsonSerializer.Deserialize<Configuration>(data); configuration = new Configuration(); // TODO Implement config persistence.
this.productListingManager = new ProductListingManager();
return null;
} }
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters) public async Task InitializeAsync(string workspaceDir)
{ {
return productListingManager.Search(query, configuration.Similarity, http); ProductListingManager productListingManager = new ProductListingManager(http);
this.searchManager = new SearchManager(productListingManager, configuration.Similarity);
await productListingManager.DownloadListings();
productListingManager.StartUpdateTimer();
}
public IEnumerable<ProductListing> Search(string query, Filters filters)
{
return searchManager.Search(query);
} }
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
@ -54,6 +56,7 @@ namespace Props.Shop.Adafruit
if (disposing) if (disposing)
{ {
http.Dispose(); http.Dispose();
searchManager.Dispose();
} }
disposedValue = true; disposedValue = true;
} }

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public interface IProductListingManager : IDisposable
{
public event EventHandler DataUpdateEvent;
public IDictionary<string, IList<ProductListing>> ActiveListings { get; }
public Task DownloadListings();
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5);
public void StopUpdateTimer();
}
}

View File

@ -1,18 +1,22 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Net.Http; using System.Net.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Props.Shop.Framework; using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api namespace Props.Shop.Adafruit.Api
{ {
public class ListingsParser public class ProductListingsParser
{ {
public IEnumerable<ProductListing> ProductListings { get; private set; } public IEnumerable<ProductListing> ProductListings { get; private set; }
public ListingsParser(string json) public void BuildProductListings(Stream stream)
{ {
dynamic data = JArray.Parse(json); using (StreamReader streamReader = new StreamReader(stream))
{
dynamic data = JArray.Load(new JsonTextReader(streamReader));
List<ProductListing> parsed = new List<ProductListing>(); List<ProductListing> parsed = new List<ProductListing>();
foreach (dynamic item in data) foreach (dynamic item in data)
{ {
@ -41,4 +45,5 @@ namespace Props.Shop.Adafruit.Api
ProductListings = parsed; ProductListings = parsed;
} }
} }
}
} }

View File

@ -0,0 +1,77 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ProductListingManager : IProductListingManager
{
public event EventHandler DataUpdateEvent;
private bool disposedValue;
private volatile Dictionary<string, IList<ProductListing>> activeListings;
public IDictionary<string, IList<ProductListing>> ActiveListings => activeListings;
private ProductListingsParser parser = new ProductListingsParser();
private HttpClient httpClient;
private Timer updateTimer;
public ProductListingManager(HttpClient httpClient)
{
this.httpClient = httpClient;
}
public async Task DownloadListings() {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
HttpResponseMessage responseMessage = await httpClient.GetAsync("products");
parser.BuildProductListings(responseMessage.Content.ReadAsStream());
Dictionary<string, IList<ProductListing>> listings = new Dictionary<string, IList<ProductListing>>();
foreach (ProductListing product in parser.ProductListings)
{
IList<ProductListing> sameProducts = listings.GetValueOrDefault(product.Name);
if (sameProducts == null) {
sameProducts = new List<ProductListing>();
listings.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
activeListings = listings;
DataUpdateEvent?.Invoke(this, null);
}
public void StartUpdateTimer(int delay = 1000 * 60 * 5, int period = 1000 * 60 * 5) {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer already started.");
updateTimer = new Timer(async (state) => await DownloadListings(), null, delay, period);
}
public void StopUpdateTimer() {
if (disposedValue) throw new ObjectDisposedException("ProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer not started.");
updateTimer.Dispose();
updateTimer = null;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
updateTimer?.Dispose();
updateTimer = null;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -1,58 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using FuzzySharp;
using FuzzySharp.Extractor;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class ProductListingManager
{
private double minutesPerRequest;
private Dictionary<string, List<ProductListing>> listings = new Dictionary<string, List<ProductListing>>();
private bool requested = false;
public DateTime TimeOfLastRequest { get; private set; }
public bool RequestReady => !requested || DateTime.Now - TimeOfLastRequest > TimeSpan.FromMinutes(minutesPerRequest);
public ProductListingManager(int requestsPerMinute = 5)
{
this.minutesPerRequest = 1 / requestsPerMinute;
}
public async Task RefreshListings(HttpClient http)
{
requested = true;
TimeOfLastRequest = DateTime.Now;
HttpResponseMessage response = await http.GetAsync("/products");
SetListingsData(await response.Content.ReadAsStringAsync());
}
public void SetListingsData(string data)
{
ListingsParser listingsParser = new ListingsParser(data);
foreach (ProductListing listing in listingsParser.ProductListings)
{
List<ProductListing> similar = listings.GetValueOrDefault(listing.Name, new List<ProductListing>());
similar.Add(listing);
listings[listing.Name] = similar;
}
}
public async IAsyncEnumerable<ProductListing> Search(string query, float similarity, HttpClient httpClient = null)
{
if (RequestReady && httpClient != null) await RefreshListings(httpClient);
IEnumerable<ExtractedResult<string>> resultNames = Process.ExtractAll(query, listings.Keys, cutoff: (int)similarity * 100);
foreach (ExtractedResult<string> resultName in resultNames)
{
foreach (ProductListing product in listings[resultName.Value])
{
yield return product;
}
}
}
}
}

View File

@ -0,0 +1,72 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using FuzzySharp;
using FuzzySharp.Extractor;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Api
{
public class SearchManager : IDisposable
{
public float Similarity { get; set; }
private readonly object searchLock = new object();
private IDictionary<string, IList<ProductListing>> searched;
private IProductListingManager listingManager;
private bool disposedValue;
public SearchManager(IProductListingManager productListingManager, float similarity = 0.8f)
{
this.listingManager = productListingManager ?? throw new ArgumentNullException("productListingManager");
this.Similarity = similarity;
listingManager.DataUpdateEvent += OnDataUpdate;
}
private void OnDataUpdate(object sender, EventArgs eventArgs)
{
BuildSearchIndex();
}
private void BuildSearchIndex()
{
lock (searchLock)
{
searched = new Dictionary<string, IList<ProductListing>>(listingManager.ActiveListings);
}
}
public IEnumerable<ProductListing> Search(string query)
{
lock (searchLock)
{
foreach (ExtractedResult<string> listingNames in Process.ExtractAll(query, searched.Keys, cutoff: (int)(Similarity * 100)))
{
foreach (ProductListing same in searched[listingNames.Value])
{
yield return same;
}
}
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
listingManager.Dispose();
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -3,5 +3,10 @@ namespace Props.Shop.Adafruit
public class Configuration public class Configuration
{ {
public float Similarity { get; set; } public float Similarity { get; set; }
public Configuration()
{
Similarity = 0.8f;
}
} }
} }

View File

@ -1,35 +0,0 @@
using System;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Options
{
public class SimilarityOption : IOption
{
private Configuration configuration;
public string Name => "Query Similarity";
public string Description => "The minimum level of similarity for a listing to be returned.";
public bool Required => true;
public Type Type => typeof(float);
internal SimilarityOption(Configuration configuration)
{
this.configuration = configuration;
}
public string GetValue()
{
return configuration.Similarity.ToString();
}
public bool SetValue(string value)
{
float parsed;
bool success = float.TryParse(value, out parsed);
configuration.Similarity = parsed;
return success;
}
}
}

View File

@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FuzzySharp" Version="2.0.2" /> <PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="System.Linq.Async" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
using Props.Shop.Framework; using Props.Shop.Framework;
namespace Props.Shop.Ebay namespace Props.Shop.Ebay
@ -29,13 +30,16 @@ namespace Props.Shop.Ebay
private HttpClient httpClient; private HttpClient httpClient;
public IEnumerable<IOption> Initialize(byte[] data) public void Initialize(string workspaceDir)
{ {
httpClient = new HttpClient(); httpClient = new HttpClient();
configuration = JsonSerializer.Deserialize<Configuration>(data); configuration = new Configuration(); // TODO: Implement config persistence.
return new List<IOption>() { }
new SandboxOption(configuration),
};
public Task InitializeAsync(string workspaceDir)
{
throw new NotImplementedException();
} }
protected virtual void Dispose(bool disposing) protected virtual void Dispose(bool disposing)
@ -61,7 +65,7 @@ namespace Props.Shop.Ebay
return JsonSerializer.SerializeToUtf8Bytes(configuration); return JsonSerializer.SerializeToUtf8Bytes(configuration);
} }
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters) public IEnumerable<ProductListing> Search(string query, Filters filters)
{ {
// TODO: Implement the search system. // TODO: Implement the search system.
throw new NotImplementedException(); throw new NotImplementedException();

View File

@ -1,35 +0,0 @@
using System;
using Props.Shop.Framework;
namespace Props.Shop.Ebay
{
public class SandboxOption : IOption
{
private Configuration configuration;
public string Name => "Ebay Sandbox";
public string Description => "For development purposes, Ebay Sandbox allows use of Ebay APIs (with exceptions) in a sandbox environment before applying for production use.";
public bool Required => true;
public Type Type => typeof(bool);
internal SandboxOption(Configuration configuration)
{
this.configuration = configuration;
}
public string GetValue()
{
return configuration.Sandbox.ToString();
}
public bool SetValue(string value)
{
bool sandbox = false;
bool res = bool.TryParse(value, out sandbox);
configuration.Sandbox = sandbox;
return res;
}
}
}

View File

@ -12,10 +12,10 @@ namespace Props.Shop.Framework
string ShopDescription { get; } string ShopDescription { get; }
string ShopModuleAuthor { get; } string ShopModuleAuthor { get; }
public IAsyncEnumerable<ProductListing> Search(string query, Filters filters); public IEnumerable<ProductListing> Search(string query, Filters filters);
IEnumerable<IOption> Initialize(byte[] data); void Initialize(string workspaceDir);
Task InitializeAsync(string workspaceDir);
public SupportedFeatures SupportedFeatures { get; } public SupportedFeatures SupportedFeatures { get; }
public byte[] GetDataForPersistence();
} }
} }

View File

@ -1,6 +1,6 @@
namespace Props.Shop.Framework namespace Props.Shop.Framework
{ {
public struct ProductListing public class ProductListing
{ {
public float LowerPrice { get; set; } public float LowerPrice { get; set; }
public float UpperPrice { get; set; } public float UpperPrice { get; set; }

View File

@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests
{
public class AdafruitShopTest
{
[Fact]
public async Task TestSearch() {
AdafruitShop mockAdafruitShop = new AdafruitShop();
mockAdafruitShop.Initialize(null);
await mockAdafruitShop.InitializeAsync(null);
int count = 0;
foreach (ProductListing listing in mockAdafruitShop.Search("raspberry pi", new Filters()))
{
count += 1;
}
Assert.True(count > 0);
}
}
}

View File

@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
namespace Props.Shop.Adafruit.Tests.Api
{
public class FakeProductListingManager : IProductListingManager
{
private ProductListingsParser parser;
private Timer updateTimer;
private bool disposedValue;
private volatile Dictionary<string, IList<ProductListing>> activeListings;
public IDictionary<string, IList<ProductListing>> ActiveListings => activeListings;
public event EventHandler DataUpdateEvent;
public FakeProductListingManager()
{
parser = new ProductListingsParser();
}
public Task DownloadListings()
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
parser.BuildProductListings(stream);
}
Dictionary<string, IList<ProductListing>> results = new Dictionary<string, IList<ProductListing>>();
foreach (ProductListing product in parser.ProductListings)
{
IList<ProductListing> sameProducts = results.GetValueOrDefault(product.Name);
if (sameProducts == null) {
sameProducts = new List<ProductListing>();
results.Add(product.Name, sameProducts);
}
sameProducts.Add(product);
}
activeListings = results;
DataUpdateEvent?.Invoke(this, null);
return Task.CompletedTask;
}
public void StartUpdateTimer(int delay = 300000, int period = 300000)
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
if (updateTimer != null) throw new InvalidOperationException("Update timer already started.");
updateTimer = new Timer((state) => DownloadListings(), null, delay, period);
}
public void StopUpdateTimer()
{
if (disposedValue) throw new ObjectDisposedException("FakeProductListingManager");
if (updateTimer == null) throw new InvalidOperationException("Update timer not started.");
updateTimer.Dispose();
updateTimer = null;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
updateTimer?.Dispose();
updateTimer = null;
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -10,7 +10,11 @@ namespace Props.Shop.Adafruit.Tests
[Fact] [Fact]
public void TestParsing() public void TestParsing()
{ {
ListingsParser mockParser = new ListingsParser(File.ReadAllText("./Assets/products.json")); ProductListingsParser mockParser = new ProductListingsParser();
using (Stream stream = File.OpenRead("./Assets/products.json"))
{
mockParser.BuildProductListings(stream);
}
Assert.NotEmpty(mockParser.ProductListings); Assert.NotEmpty(mockParser.ProductListings);
} }
} }

View File

@ -1,25 +0,0 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Props.Shop.Framework;
using Xunit;
namespace Props.Shop.Adafruit.Tests.Api
{
public class ProductListingManagerTest
{
[Fact]
public async Task TestSearch()
{
ProductListingManager mockProductListingManager = new ProductListingManager();
mockProductListingManager.SetListingsData(File.ReadAllText("./Assets/products.json"));
List<ProductListing> results = new List<ProductListing>();
await foreach (ProductListing item in mockProductListingManager.Search("arduino", 0.5f))
{
results.Add(item);
}
Assert.NotEmpty(results);
}
}
}

View File

@ -0,0 +1,20 @@
using System.Threading.Tasks;
using Props.Shop.Adafruit.Api;
using Xunit;
namespace Props.Shop.Adafruit.Tests.Api
{
public class SearchManagerTest
{
[Fact]
public async Task SearchTest()
{
FakeProductListingManager stubProductListingManager = new FakeProductListingManager();
SearchManager searchManager = new SearchManager(stubProductListingManager);
await stubProductListingManager.DownloadListings();
searchManager.Similarity = 0.8f;
Assert.NotEmpty(searchManager.Search("Raspberry Pi"));
searchManager.Dispose();
}
}
}

View File

@ -5,4 +5,5 @@
], ],
"todo-tree.regex.regex": "((//|#|<!--|;|@\\*|/\\*|^)\\s*($TAGS)|^\\s*- \\[ \\])", "todo-tree.regex.regex": "((//|#|<!--|;|@\\*|/\\*|^)\\s*($TAGS)|^\\s*- \\[ \\])",
"todo-tree.regex.subTagRegex": "(\\*@)", "todo-tree.regex.subTagRegex": "(\\*@)",
"editor.formatOnSave": true,
} }

View File

@ -6,7 +6,8 @@
} }
<div class="jumbotron text-center"> <div class="jumbotron text-center">
<img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;" asp-append-version="true" /> <img alt="Props logo" src="~/images/logo-simplified.svg" class="img-fluid" style="max-height: 150px;"
asp-append-version="true" />
<h1 class="mt-3 mb-4 display-1">@ViewData["Title"]</h1> <h1 class="mt-3 mb-4 display-1">@ViewData["Title"]</h1>
<p>Welcome back!</p> <p>Welcome back!</p>
</div> </div>
@ -40,13 +41,16 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<a class="link-secondary" id="forgot-password" asp-page="./ForgotPassword">Forgot your password?</a> <a class="link-secondary" id="forgot-password" asp-page="./ForgotPassword">Forgot your
password?</a>
</div> </div>
<div class="col-lg"> <div class="col-lg">
<a class="link-secondary" asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register as a new user</a> <a class="link-secondary" asp-page="./Register" asp-route-returnUrl="@Model.ReturnUrl">Register
as a new user</a>
</div> </div>
<div class="col-lg"> <div class="col-lg">
<a class="link-secondary" id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend email confirmation</a> <a class="link-secondary" id="resend-confirmation" asp-page="./ResendEmailConfirmation">Resend
email confirmation</a>
</div> </div>
</div> </div>
</form> </form>
@ -56,12 +60,14 @@
<div class="col-md-6 md-offset-2"> <div class="col-md-6 md-offset-2">
<h4>Use another service to log in.</h4> <h4>Use another service to log in.</h4>
<hr /> <hr />
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal"> <form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post"
class="form-horizontal">
<div> <div>
<p> <p>
@foreach (var provider in Model.ExternalLogins) @foreach (var provider in Model.ExternalLogins)
{ {
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button> <button type="submit" class="btn btn-primary" name="provider" value="@provider.Name"
title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
} }
</p> </p>
</div> </div>

View File

@ -3,15 +3,22 @@
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
} }
<ul class="nav nav-pills flex-column"> <ul class="nav nav-pills flex-column">
<li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li> <li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile"
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li> asp-page="./Index">Profile</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li> <li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email"
asp-page="./Email">Email</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password"
asp-page="./ChangePassword">Password</a></li>
@if (hasExternalLogins) @if (hasExternalLogins)
{ {
<li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li> <li id="external-logins" class="nav-item"><a id="external-login"
class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External
logins</a></li>
} }
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li> <li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)"
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">Personal data</a></li> id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data"
asp-page="./PersonalData">Personal data</a></li>
</ul> </ul>
@* TODO: Finish styling this page. *@ @* TODO: Finish styling account page. *@

View File

@ -9,8 +9,10 @@
if (@Model.DisplayConfirmAccountLink) if (@Model.DisplayConfirmAccountLink)
{ {
<p> <p>
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender. This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your account</a> docs</a> for how to configure a real email sender.
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your
account</a>
</p> </p>
} }
else else
@ -20,4 +22,4 @@
</p> </p>
} }
} }
@* TODO: Do something about this. *@ @* TODO: implement account confirmation. *@

16
Props/Content/search.json Normal file
View File

@ -0,0 +1,16 @@
{
"instructions": [
"Type in the electronic component your looking for in the search bar above.",
"Optionally, hit the configure button to modify your search criteria. With an account, you can even create and save multiple search profiles known as \"search outlines\".",
"That's it! Hit search and we'll look far and wide for what you need!"
],
"quickPicks": {
"searched": "To save you some time, these are some of the better options we found!",
"prompt": "This is where we'll show you top listings so you can get back to working on your project!"
},
"results": {
"searched": "We searched far and wide. Here's what we found!",
"prompt": "This is were we'll display all the results we went through to show you the top. This is a good place to check if none of our quick picks cater to your needs."
},
"notSearched": "Nothing to show yet!"
}

View File

@ -15,24 +15,10 @@ namespace Props.Controllers
} }
[HttpGet] [HttpGet]
[Route("Shops/Available")] [Route("Available")]
public IActionResult GetAvailableShops() public IActionResult GetAvailableShops()
{ {
return Ok(shopManager.AvailableShops()); return Ok(shopManager.GetAllShopNames());
}
[HttpGet]
[Route("Default/Filters")]
public IActionResult GetDefaultFilters()
{
return Ok(defaultOutline.Filters);
}
[HttpGet]
[Route("Default/DisabledShops")]
public IActionResult GetDefaultDisabledShops()
{
return Ok(defaultOutline.Disabled);
} }
} }
} }

View File

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search;
using Props.Services.Modules;
namespace Props.Controllers
{
public class SearchOutlineController : ApiControllerBase
{
private SearchOutline defaultOutline = new SearchOutline();
public SearchOutlineController()
{
}
[HttpGet]
[Route("Filters")]
public IActionResult GetFilters()
{
return Ok(defaultOutline.Filters);
}
[HttpGet]
[Route("DisabledShops")]
public IActionResult GetDisabledShops()
{
return Ok(defaultOutline.Enabled);
}
[HttpGet]
[Route("SearchOutlineName")]
public IActionResult GetSearchOutlineName()
{
return Ok(defaultOutline.Name);
}
}
}

View File

@ -3,10 +3,11 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Localization;
using Props.Models; using Props.Models;
using Props.Models.Search; using Props.Models.Search;
using Props.Models.User; using Props.Models.User;
@ -16,8 +17,10 @@ namespace Props.Data
{ {
public class ApplicationDbContext : IdentityDbContext<ApplicationUser> public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{ {
DbSet<SearchOutline> SearchOutlines { get; set; } public DbSet<QueryWordInfo> Keywords { get; set; }
DbSet<ProductListingInfo> TrackedListings { get; set; }
public DbSet<ProductListingInfo> ProductListingInfos { get; set; }
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) : base(options)
{ {
@ -40,7 +43,7 @@ namespace Props.Data
); );
modelBuilder.Entity<SearchOutline>() modelBuilder.Entity<SearchOutline>()
.Property(e => e.Disabled) .Property(e => e.Enabled)
.HasConversion( .HasConversion(
v => JsonSerializer.Serialize(v, null), v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null), v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null),
@ -50,6 +53,7 @@ namespace Props.Data
c => c.Copy() c => c.Copy()
) )
); );
modelBuilder.Entity<SearchOutline>() modelBuilder.Entity<SearchOutline>()
.Property(e => e.Filters) .Property(e => e.Filters)
.HasConversion( .HasConversion(

View File

@ -9,7 +9,7 @@ using Props.Data;
namespace Props.Data.Migrations namespace Props.Data.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20210724073427_InitialCreate")] [Migration("20210805055109_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -188,18 +188,36 @@ namespace Props.Data.Migrations
b.Property<DateTime>("LastUpdated") b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("OriginName")
.HasColumnType("TEXT");
b.Property<string>("ProductName") b.Property<string>("ProductName")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ProductUrl") b.Property<string>("ProductUrl")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("TrackedListings"); b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Keywords");
}); });
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -212,18 +230,27 @@ namespace Props.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Disabled") b.Property<string>("Enabled")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Filters") b.Property<string>("Filters")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ApplicationUserId"); b.HasIndex("ApplicationUserId");
b.ToTable("SearchOutlines"); b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
}); });
modelBuilder.Entity("Props.Models.User.ApplicationUser", b => modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
@ -290,6 +317,29 @@ namespace Props.Data.Migrations
b.ToTable("AspNetUsers"); b.ToTable("AspNetUsers");
}); });
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ActiveSearchOutlineId");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b => modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -314,6 +364,21 @@ namespace Props.Data.Migrations
b.ToTable("ApplicationPreferences"); b.ToTable("ApplicationPreferences");
}); });
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -379,11 +444,32 @@ namespace Props.Data.Migrations
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{ {
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser") b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithMany("SearchOutlines") .WithMany()
.HasForeignKey("ApplicationUserId") .HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null)
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });
@ -398,6 +484,21 @@ namespace Props.Data.Migrations
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b => modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{ {
b.Navigation("ApplicationPreferences") b.Navigation("ApplicationPreferences")
@ -406,6 +507,12 @@ namespace Props.Data.Migrations
b.Navigation("ResultsPreferences") b.Navigation("ResultsPreferences")
.IsRequired(); .IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines"); b.Navigation("SearchOutlines");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618

View File

@ -47,12 +47,26 @@ namespace Props.Data.Migrations
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "TrackedListings", name: "Keywords",
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "INTEGER", nullable: false) Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true), .Annotation("Sqlite:Autoincrement", true),
OriginName = table.Column<string>(type: "TEXT", nullable: true), Word = table.Column<string>(type: "TEXT", nullable: false),
Hits = table.Column<uint>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Keywords", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProductListingInfos",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ShopName = table.Column<string>(type: "TEXT", nullable: true),
Hits = table.Column<uint>(type: "INTEGER", nullable: false), Hits = table.Column<uint>(type: "INTEGER", nullable: false),
LastUpdated = table.Column<DateTime>(type: "TEXT", nullable: false), LastUpdated = table.Column<DateTime>(type: "TEXT", nullable: false),
ProductUrl = table.Column<string>(type: "TEXT", nullable: true), ProductUrl = table.Column<string>(type: "TEXT", nullable: true),
@ -60,7 +74,7 @@ namespace Props.Data.Migrations
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_TrackedListings", x => x.Id); table.PrimaryKey("PK_ProductListingInfos", x => x.Id);
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
@ -212,26 +226,78 @@ namespace Props.Data.Migrations
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "SearchOutlines", name: "QueryWordInfoQueryWordInfo",
columns: table => new
{
FollowingId = table.Column<int>(type: "INTEGER", nullable: false),
PrecedingId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryWordInfoQueryWordInfo", x => new { x.FollowingId, x.PrecedingId });
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_FollowingId",
column: x => x.FollowingId,
principalTable: "Keywords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_PrecedingId",
column: x => x.PrecedingId,
principalTable: "Keywords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SearchOutline",
columns: table => new columns: table => new
{ {
Id = table.Column<int>(type: "INTEGER", nullable: false) Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true), .Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false), ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Filters = table.Column<string>(type: "TEXT", nullable: true), Filters = table.Column<string>(type: "TEXT", nullable: true),
Disabled = table.Column<string>(type: "TEXT", nullable: false) Enabled = table.Column<string>(type: "TEXT", nullable: false),
SearchOutlinePreferencesId = table.Column<int>(type: "INTEGER", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_SearchOutlines", x => x.Id); table.PrimaryKey("PK_SearchOutline", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_SearchOutlines_AspNetUsers_ApplicationUserId", name: "FK_SearchOutline_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId, column: x => x.ApplicationUserId,
principalTable: "AspNetUsers", principalTable: "AspNetUsers",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "SearchOutlinePreferences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
ActiveSearchOutlineId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SearchOutlinePreferences", x => x.Id);
table.ForeignKey(
name: "FK_SearchOutlinePreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SearchOutlinePreferences_SearchOutline_ActiveSearchOutlineId",
column: x => x.ActiveSearchOutlineId,
principalTable: "SearchOutline",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ApplicationPreferences_ApplicationUserId", name: "IX_ApplicationPreferences_ApplicationUserId",
table: "ApplicationPreferences", table: "ApplicationPreferences",
@ -275,6 +341,11 @@ namespace Props.Data.Migrations
column: "NormalizedUserName", column: "NormalizedUserName",
unique: true); unique: true);
migrationBuilder.CreateIndex(
name: "IX_QueryWordInfoQueryWordInfo_PrecedingId",
table: "QueryWordInfoQueryWordInfo",
column: "PrecedingId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_ResultsPreferences_ApplicationUserId", name: "IX_ResultsPreferences_ApplicationUserId",
table: "ResultsPreferences", table: "ResultsPreferences",
@ -282,13 +353,49 @@ namespace Props.Data.Migrations
unique: true); unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_SearchOutlines_ApplicationUserId", name: "IX_SearchOutline_ApplicationUserId",
table: "SearchOutlines", table: "SearchOutline",
column: "ApplicationUserId"); column: "ApplicationUserId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutline_SearchOutlinePreferencesId",
table: "SearchOutline",
column: "SearchOutlinePreferencesId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ActiveSearchOutlineId",
table: "SearchOutlinePreferences",
column: "ActiveSearchOutlineId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ApplicationUserId",
table: "SearchOutlinePreferences",
column: "ApplicationUserId",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
table: "SearchOutline",
column: "SearchOutlinePreferencesId",
principalTable: "SearchOutlinePreferences",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
} }
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey(
name: "FK_SearchOutline_AspNetUsers_ApplicationUserId",
table: "SearchOutline");
migrationBuilder.DropForeignKey(
name: "FK_SearchOutlinePreferences_AspNetUsers_ApplicationUserId",
table: "SearchOutlinePreferences");
migrationBuilder.DropForeignKey(
name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
table: "SearchOutline");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "ApplicationPreferences"); name: "ApplicationPreferences");
@ -307,20 +414,29 @@ namespace Props.Data.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "AspNetUserTokens"); name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "ProductListingInfos");
migrationBuilder.DropTable(
name: "QueryWordInfoQueryWordInfo");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "ResultsPreferences"); name: "ResultsPreferences");
migrationBuilder.DropTable(
name: "SearchOutlines");
migrationBuilder.DropTable(
name: "TrackedListings");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "AspNetRoles"); name: "AspNetRoles");
migrationBuilder.DropTable(
name: "Keywords");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "AspNetUsers"); name: "AspNetUsers");
migrationBuilder.DropTable(
name: "SearchOutlinePreferences");
migrationBuilder.DropTable(
name: "SearchOutline");
} }
} }
} }

View File

@ -186,18 +186,36 @@ namespace Props.Data.Migrations
b.Property<DateTime>("LastUpdated") b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("OriginName")
.HasColumnType("TEXT");
b.Property<string>("ProductName") b.Property<string>("ProductName")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ProductUrl") b.Property<string>("ProductUrl")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ShopName")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("TrackedListings"); b.ToTable("ProductListingInfos");
});
modelBuilder.Entity("Props.Models.Search.QueryWordInfo", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Hits")
.HasColumnType("INTEGER");
b.Property<string>("Word")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Keywords");
}); });
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -210,18 +228,27 @@ namespace Props.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Disabled") b.Property<string>("Enabled")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Filters") b.Property<string>("Filters")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ApplicationUserId"); b.HasIndex("ApplicationUserId");
b.ToTable("SearchOutlines"); b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline");
}); });
modelBuilder.Entity("Props.Models.User.ApplicationUser", b => modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
@ -288,6 +315,29 @@ namespace Props.Data.Migrations
b.ToTable("AspNetUsers"); b.ToTable("AspNetUsers");
}); });
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ActiveSearchOutlineId");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("SearchOutlinePreferences");
});
modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b => modelBuilder.Entity("Props.Shared.Models.User.ApplicationPreferences", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@ -312,6 +362,21 @@ namespace Props.Data.Migrations
b.ToTable("ApplicationPreferences"); b.ToTable("ApplicationPreferences");
}); });
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.Property<int>("FollowingId")
.HasColumnType("INTEGER");
b.Property<int>("PrecedingId")
.HasColumnType("INTEGER");
b.HasKey("FollowingId", "PrecedingId");
b.HasIndex("PrecedingId");
b.ToTable("QueryWordInfoQueryWordInfo");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -377,11 +442,32 @@ namespace Props.Data.Migrations
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
{ {
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser") b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithMany("SearchOutlines") .WithMany()
.HasForeignKey("ApplicationUserId") .HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null)
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });
@ -396,6 +482,21 @@ namespace Props.Data.Migrations
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });
modelBuilder.Entity("QueryWordInfoQueryWordInfo", b =>
{
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("FollowingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Props.Models.Search.QueryWordInfo", null)
.WithMany()
.HasForeignKey("PrecedingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.ApplicationUser", b => modelBuilder.Entity("Props.Models.User.ApplicationUser", b =>
{ {
b.Navigation("ApplicationPreferences") b.Navigation("ApplicationPreferences")
@ -404,6 +505,12 @@ namespace Props.Data.Migrations
b.Navigation("ResultsPreferences") b.Navigation("ResultsPreferences")
.IsRequired(); .IsRequired();
b.Navigation("searchOutlinePreferences")
.IsRequired();
});
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{
b.Navigation("SearchOutlines"); b.Navigation("SearchOutlines");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618

View File

@ -0,0 +1,14 @@
using Props.Shop.Framework;
namespace Props.Extensions
{
public static class ProductListingExtensions
{
public static float? GetRatingToPriceRatio(this ProductListing productListing)
{
int reviewFactor = productListing.ReviewCount.HasValue ? productListing.ReviewCount.Value : 1;
int purchaseFactor = productListing.PurchaseCount.HasValue ? productListing.PurchaseCount.Value : 1;
return (productListing.Rating * (reviewFactor > purchaseFactor ? reviewFactor : purchaseFactor)) / (productListing.LowerPrice * productListing.UpperPrice);
}
}
}

View File

@ -7,7 +7,7 @@ namespace Props.Models.Search
{ {
public int Id { get; set; } public int Id { get; set; }
public string OriginName { get; set; } public string ShopName { get; set; }
public uint Hits { get; set; } public uint Hits { get; set; }

View File

@ -0,0 +1,34 @@
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Props.Models.Search
{
public class QueryWordInfo
{
public int Id { get; set; }
[Required]
public string Word { get; set; }
public uint Hits { get; set; }
[Required]
public virtual ISet<QueryWordInfo> Preceding { get; set; }
[Required]
public virtual ISet<QueryWordInfo> Following { get; set; }
public QueryWordInfo()
{
this.Preceding = new HashSet<QueryWordInfo>();
this.Following = new HashSet<QueryWordInfo>();
}
public QueryWordInfo(ISet<QueryWordInfo> preceding, ISet<QueryWordInfo> following)
{
this.Preceding = preceding;
this.Following = following;
}
}
}

View File

@ -17,10 +17,13 @@ namespace Props.Models.Search
[Required] [Required]
public virtual ApplicationUser ApplicationUser { get; set; } public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public string Name { get; set; } = "Default";
public Filters Filters { get; set; } = new Filters(); public Filters Filters { get; set; } = new Filters();
[Required] [Required]
public ShopsDisabled Disabled { get; set; } = new ShopsDisabled(); public ShopsDisabled Enabled { get; set; } = new ShopsDisabled();
public sealed class ShopsDisabled : HashSet<string> public sealed class ShopsDisabled : HashSet<string>
{ {
@ -68,12 +71,26 @@ namespace Props.Models.Search
return return
Id == other.Id && Id == other.Id &&
Filters.Equals(other.Filters) && Filters.Equals(other.Filters) &&
Disabled.Equals(other.Disabled); Enabled.Equals(other.Enabled);
} }
public override int GetHashCode() public override int GetHashCode()
{ {
return Id; return HashCode.Combine(Id, Name);
}
public SearchOutline()
{
this.Name = "Default";
this.Filters = new Filters();
this.Enabled = new ShopsDisabled();
}
public SearchOutline(string name, Filters filters, ShopsDisabled disabled)
{
this.Name = name;
this.Filters = filters;
this.Enabled = disabled;
} }
} }
} }

View File

@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Props.Models.Search; using Props.Models.Search;
using Props.Shared.Models.User; using Props.Shared.Models.User;
@ -11,7 +12,8 @@ namespace Props.Models.User
{ {
public class ApplicationUser : IdentityUser public class ApplicationUser : IdentityUser
{ {
public virtual ISet<SearchOutline> SearchOutlines { get; set; } [Required]
public virtual SearchOutlinePreferences searchOutlinePreferences { get; set; }
[Required] [Required]
public virtual ResultsPreferences ResultsPreferences { get; private set; } public virtual ResultsPreferences ResultsPreferences { get; private set; }
@ -19,15 +21,16 @@ namespace Props.Models.User
[Required] [Required]
public virtual ApplicationPreferences ApplicationPreferences { get; private set; } public virtual ApplicationPreferences ApplicationPreferences { get; private set; }
// TODO: Write project system.
public ApplicationUser() public ApplicationUser()
{ {
searchOutlinePreferences = new SearchOutlinePreferences();
ResultsPreferences = new ResultsPreferences(); ResultsPreferences = new ResultsPreferences();
ApplicationPreferences = new ApplicationPreferences(); ApplicationPreferences = new ApplicationPreferences();
} }
public ApplicationUser(ResultsPreferences resultsPreferences, ApplicationPreferences applicationPreferences) public ApplicationUser(SearchOutlinePreferences searchOutlinePreferences, ResultsPreferences resultsPreferences, ApplicationPreferences applicationPreferences)
{ {
this.searchOutlinePreferences = searchOutlinePreferences;
this.ResultsPreferences = resultsPreferences; this.ResultsPreferences = resultsPreferences;
this.ApplicationPreferences = applicationPreferences; this.ApplicationPreferences = applicationPreferences;
} }

View File

@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Props.Models.Search;
using Props.Models.User;
namespace Props.Models.User
{
public class SearchOutlinePreferences
{
public int Id { get; set; }
[Required]
public string ApplicationUserId { get; set; }
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public virtual ISet<SearchOutline> SearchOutlines { get; set; }
[Required]
public virtual SearchOutline ActiveSearchOutline { get; set; }
public SearchOutlinePreferences()
{
SearchOutlines = new HashSet<SearchOutline>();
ActiveSearchOutline = new SearchOutline();
SearchOutlines.Add(ActiveSearchOutline);
}
public SearchOutlinePreferences(ISet<SearchOutline> searchOutlines, SearchOutline activeSearchOutline)
{
this.SearchOutlines = searchOutlines;
this.ActiveSearchOutline = activeSearchOutline;
}
}
}

View File

@ -5,7 +5,6 @@ namespace Props.Options
public const string Modules = "Modules"; public const string Modules = "Modules";
public string ShopsDir { get; set; } public string ShopsDir { get; set; }
public bool RecursiveLoad { get; set; } public bool RecursiveLoad { get; set; }
public int MaxResults { get; set; }
public string ShopRegex { get; set; } public string ShopRegex { get; set; }
} }
} }

View File

@ -0,0 +1,9 @@
namespace Props.Options
{
public class SearchOptions
{
public const string Search = "Search";
public int MaxResults { get; set; }
}
}

View File

@ -1,25 +1,32 @@
@page @page
@using Props.Services.Content
@model SearchModel
@inject IContentManager<SearchModel> ContentManager
@{ @{
ViewData["Title"] = "Search"; ViewData["Title"] = "Search";
ViewData["Specific"] = "Search"; ViewData["Specific"] = "Search";
} }
<div> <div class="mt-4 mb-3">
<div class="my-4 less-concise mx-auto"> <div class="less-concise mx-auto">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control bg-transparent border-primary" placeholder="What are you looking for?" aria-label="Search" aria-describedby="search-btn" id="search-bar"> <input type="text" class="form-control border-primary" placeholder="What are you looking for?"
<button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse" data-bs-target="#configuration"><i class="bi bi-sliders"></i></button> aria-label="Search" aria-describedby="search-btn" id="search-bar" value="@Model.SearchQuery">
<button class="btn btn-outline-primary" type="button" id="search-btn">Search</button> <button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-sliders"></i></button>
<button class="btn btn-primary" type="button" id="search-btn">Search</button>
</div> </div>
</div> </div>
</div> </div>
<div class="collapse tear" id="configuration"> <div class="collapse tear" id="configuration" x-data="configuration">
<div class="p-3"> <div class="p-3">
<div class="container invisible"> <div class="container">
<div class="d-flex"> <div class="d-flex">
<h1 class="my-2 display-2 me-auto">Configuration</h1> <h1 class="my-2 display-2 me-auto">Configuration</h1>
<button class="btn align-self-start" type="button" id="configuration-close" data-bs-toggle="collapse" data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button> <button class="btn align-self-start" type="button" id="configuration-close" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
</div> </div>
<div class="row justify-content-md-center"> <div class="row justify-content-md-center">
<section class="col-lg px-4"> <section class="col-lg px-4">
@ -28,10 +35,11 @@
<label for="max-price" class="form-label">Maximum Price</label> <label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group"> <div class="input-group">
<div class="input-group-text"> <div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"> <input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
x-model="maxPriceEnabled">
</div> </div>
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-price"> <input type="number" class="form-control" min="0" id="max-price" x-model="maxPrice">
<span class="input-group-text">.00</span> <span class="input-group-text">.00</span>
</div> </div>
</div> </div>
@ -39,7 +47,7 @@
<label for="min-price" class="form-label">Minimum Price</label> <label for="min-price" class="form-label">Minimum Price</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="min-price"> <input type="number" class="form-control" min="0" id="min-price" x-model="minPrice">
<span class="input-group-text">.00</span> <span class="input-group-text">.00</span>
</div> </div>
</div> </div>
@ -50,13 +58,14 @@
<input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled"> <input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled">
</div> </div>
<span class="input-group-text">$</span> <span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-shipping"> <input type="number" class="form-control" min="0" id="max-shipping" x-model="maxShipping">
<span class="input-group-text">.00</span> <span class="input-group-text">.00</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping"> <input class="form-check-input" type="checkbox" id="keep-unknown-shipping"
x-model="keepUnknownShipping">
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown Shipping</label> <label class="form-check-label" for="keep-unknown-shipping">Keep Unknown Shipping</label>
</div> </div>
</div> </div>
@ -66,37 +75,41 @@
<div class="mb-3"> <div class="mb-3">
<label for="min-purchases" class="form-label">Minimum Purchases</label> <label for="min-purchases" class="form-label">Minimum Purchases</label>
<div class="input-group"> <div class="input-group">
<input type="number" class="form-control" min="0" id="min-purchases"> <input type="number" class="form-control" min="0" id="min-purchases" x-model="minPurchases">
<span class="input-group-text">Purchases</span> <span class="input-group-text">Purchases</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases"> <input class="form-check-input" type="checkbox" id="keep-unknown-purchases"
x-model="keepUnknownPurchases">
<label class="form-check-label" for="keep-unknown-purchases">Keep Unknown Purchases</label> <label class="form-check-label" for="keep-unknown-purchases">Keep Unknown Purchases</label>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="min-reviews" class="form-label">Minimum Reviews</label> <label for="min-reviews" class="form-label">Minimum Reviews</label>
<div class="input-group"> <div class="input-group">
<input type="number" class="form-control" min="0" id="min-reviews"> <input type="number" class="form-control" min="0" id="min-reviews" x-model="minReviews">
<span class="input-group-text">Reviews</span> <span class="input-group-text">Reviews</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews"> <input class="form-check-input" type="checkbox" id="keep-unknown-reviews"
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number of Reviews</label> x-model="keepUnknownReviews">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number of
Reviews</label>
</div> </div>
</div> </div>
<div class="mb-1"> <div class="mb-1">
<label for="min-rating" class="form-label">Minimum Rating</label> <label for="min-rating" class="form-label">Minimum Rating</label>
<input type="range" class="form-range" id="min-rating" min="0" max="100" step="1"> <input type="range" class="form-range" id="min-rating" min="0" max="100" step="1"
<div id="min-rating-display" class="form-text"></div> x-model="minRating">
<div id="min-rating-display" class="form-text">Minimum rating: <b x-text="minRating"></b>%</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unrated"> <input class="form-check-input" type="checkbox" id="keep-unrated" x-model="keepUnrated">
<label class="form-check-label" for="keep-unrated">Keep Unrated Items</label> <label class="form-check-label" for="keep-unrated">Keep Unrated Items</label>
</div> </div>
</div> </div>
@ -104,6 +117,14 @@
<section class="col-lg px-4"> <section class="col-lg px-4">
<h3>Shops Enabled</h3> <h3>Shops Enabled</h3>
<div class="mb-3 px-3" id="shop-checkboxes"> <div class="mb-3 px-3" id="shop-checkboxes">
<template x-for="shop in Object.keys(shops)">
<div class="form-check">
<input class="form-check-input" type="checkbox" :id="`${shop}-enabled`"
x-model="shops[shop]">
<label class="form-check-label" :for="`${shop}-enabled`"><span
x-text="shop"></span></label>
</div>
</template>
</div> </div>
</section> </section>
</div> </div>
@ -111,4 +132,111 @@
</div> </div>
</div> </div>
@* TODO: Add results display and default results display *@ <div id="content-pages" class="multipage mt-3 invisible">
<ul class="nav nav-pills selectors">
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#quick-picks-slide"><i
class="bi bi-stopwatch"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#results-slide"><i
class="bi bi-view-list"></i></button>
</li>
<li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#info-slide"><i
class="bi bi-info-lg"></i></button>
</li>
</ul>
<div class="multipage-slides tab-content">
<div class="multipage-slide tab-pane fade" id="quick-picks-slide">
<div class="multipage-title">
<h1 class="display-2"><i class="bi bi-stopwatch"></i> Quick Picks</h1>
@if (Model.SearchResults != null)
{
<p>@ContentManager.Json.quickPicks.searched</p>
}
else
{
<p>@ContentManager.Json.quickPicks.prompt</p>
}
<hr class="less-concise">
</div>
<div class="multipage-content">
@if (Model.SearchResults != null)
{
@if (Model.BestRatingPriceRatio != null)
{
<p>We found this product to have the best rating to price ratio.</p>
}
@if (Model.TopRated != null)
{
<p>This listing was the one that had the highest rating.</p>
}
@if (Model.MostPurchases != null)
{
<p>This listing has the most purchases.</p>
}
@if (Model.MostReviews != null)
{
<p>This listing had the most reviews.</p>
}
@if (Model.BestPrice != null)
{
<p>Looking for the lowest price? Well here it is.</p>
}
@* TODO: Add display for top results. *@
}
else
{
<div class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@ContentManager.Json.notSearched</h2>
</div>
}
</div>
</div>
<div class="multipage-slide tab-pane fade" id="results-slide" x-data>
<div class="multipage-title">
<h2><i class="bi bi-view-list"></i> Results</h2>
@if (Model.SearchResults != null)
{
<p>@ContentManager.Json.results.searched</p>
}
else
{
<p>@ContentManager.Json.results.prompt</p>
}
<hr class="less-concise">
</div>
<div class="multipage-content">
@if (Model.SearchResults != null)
{
@* TODO: Display results with UI for sorting and changing views. *@
}
else
{
<div class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@ContentManager.Json.notSearched</h2>
</div>
}
</div>
</div>
<div class="multipage-slide tab-pane fade" id="info-slide">
<div class="multipage-content">
<div class="less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h1 class="display-3"><i class="bi bi-info-circle"></i> Get Started!</h1>
<ol>
@foreach (string instruction in ContentManager.Json.instructions)
{
<li>@instruction</li>
}
</ol>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,9 +1,54 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Castle.Core.Internal;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Props.Data;
using Props.Extensions;
using Props.Models.Search;
using Props.Models.User;
using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Pages namespace Props.Pages
{ {
public class SearchModel : PageModel public class SearchModel : PageModel
{ {
// TODO: Complete the search model. [BindProperty(Name = "q", SupportsGet = true)]
public string SearchQuery { get; set; }
public IEnumerable<ProductListing> SearchResults { get; private set; }
public ProductListing BestRatingPriceRatio { get; private set; }
public ProductListing TopRated { get; private set; }
public ProductListing MostPurchases { get; private set; }
public ProductListing MostReviews { get; private set; }
public ProductListing BestPrice { get; private set; }
private ISearchManager searchManager;
private UserManager<ApplicationUser> userManager;
private IMetricsManager analytics;
public SearchModel(ISearchManager searchManager, UserManager<ApplicationUser> userManager, IMetricsManager analyticsManager)
{
this.searchManager = searchManager;
this.userManager = userManager;
this.analytics = analyticsManager;
}
public async Task OnGet()
{
if (string.IsNullOrWhiteSpace(SearchQuery)) return;
SearchOutline activeSearchOutline = User.Identity.IsAuthenticated ? (await userManager.GetUserAsync(User)).searchOutlinePreferences.ActiveSearchOutline : new SearchOutline();
this.SearchResults = searchManager.Search(SearchQuery, activeSearchOutline);
BestRatingPriceRatio = (from result in SearchResults orderby result.GetRatingToPriceRatio() descending select result).FirstOrDefault((listing) => listing.GetRatingToPriceRatio() >= 0.5f);
TopRated = (from result in SearchResults orderby result.Rating descending select result).FirstOrDefault();
MostPurchases = (from result in SearchResults orderby result.PurchaseCount descending select result).FirstOrDefault();
MostReviews = (from result in SearchResults orderby result.ReviewCount descending select result).FirstOrDefault();
BestPrice = (from result in SearchResults orderby result.UpperPrice descending select result).FirstOrDefault();
}
} }
} }

View File

@ -11,6 +11,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Props</title> <title>@ViewData["Title"] - Props</title>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
@if (!string.IsNullOrEmpty((ViewData["Specific"] as string)))
{
<script defer src="@($"~/js/specific/{(ViewData["Specific"])}.js")" asp-append-version="true"></script>
}
</head> </head>
<body class="theme-light"> <body class="theme-light">
@ -34,10 +38,12 @@
@if (SignInManager.IsSignedIn(User)) @if (SignInManager.IsSignedIn(User))
{ {
<li class="nav-item"> <li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello @User.Identity.Name!</nav-link> <nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Manage/Index"
title="Manage">Hello @User.Identity.Name!</nav-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post"> <form class="form-inline" asp-area="Identity" asp-page="/Account/Logout"
asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link">Logout</button> <button type="submit" class="nav-link btn btn-link">Logout</button>
</form> </form>
</li> </li>
@ -45,7 +51,8 @@
else else
{ {
<li class="nav-item"> <li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Register">Register</nav-link> <nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Register">Register
</nav-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Login">Login</nav-link> <nav-link class="nav-link" asp-area="Identity" asp-page="/Account/Login">Login</nav-link>
@ -62,11 +69,6 @@
<footer id="footer"> <footer id="footer">
&copy; 2021 - Props - <a asp-area="" asp-page="/Privacy">Privacy</a> &copy; 2021 - Props - <a asp-area="" asp-page="/Privacy">Privacy</a>
</footer> </footer>
@if (!string.IsNullOrEmpty((ViewData["Specific"] as string)))
{
<script src="@($"~/js/specific/{(ViewData["Specific"])}.js")" asp-append-version="true"></script>
}
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
</body> </body>

View File

@ -35,6 +35,9 @@
<Content Include=".\content\**\*"> <Content Include=".\content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include=".\shops\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface IMetricsManager
{
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max = 10);
public IEnumerable<string> RetrieveCommonKeywords(int max = 50);
public void RegisterSearchQuery(string query);
public void RegisterListing(ProductListing productListing, string shopName);
}
}

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public interface ISearchManager
{
public IEnumerable<ProductListing> Search(string query, SearchOutline searchOutline);
}
}

View File

@ -1,3 +1,4 @@
using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -6,9 +7,10 @@ using Props.Shop.Framework;
namespace Props.Services.Modules namespace Props.Services.Modules
{ {
public interface IShopManager public interface IShopManager : IDisposable
{ {
public IEnumerable<string> AvailableShops(); public IEnumerable<string> GetAllShopNames();
public Task<IList<ProductListing>> Search(string query, SearchOutline searchOutline); public IShop GetShop(string name);
public IEnumerable<IShop> GetAllShops();
} }
} }

View File

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveMetricsManager : IMetricsManager
{
private ILogger<LiveMetricsManager> logger;
ApplicationDbContext dbContext;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger)
{
this.logger = logger;
this.dbContext = dbContext;
}
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max)
{
if (dbContext.ProductListingInfos == null) return null;
return (from l in dbContext.ProductListingInfos
orderby l.Hits descending
select l).Take(max);
}
public IEnumerable<string> RetrieveCommonKeywords(int max)
{
if (dbContext.Keywords == null) return null;
return (from k in dbContext.Keywords
orderby k.Hits descending
select k.Word).Take(max);
}
public void RegisterSearchQuery(string query)
{
query = query.ToLower();
string[] tokens = query.Split(' ');
QueryWordInfo[] wordInfos = new QueryWordInfo[tokens.Length];
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
QueryWordInfo queryWordInfo = dbContext.Keywords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo();
if (queryWordInfo.Hits == 0)
{
queryWordInfo.Word = tokens[wordIndex];
dbContext.Keywords.Add(queryWordInfo);
}
queryWordInfo.Hits += 1;
wordInfos[wordIndex] = queryWordInfo;
}
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{
for (int beforeIndex = 0; beforeIndex < wordIndex; beforeIndex++)
{
wordInfos[wordIndex].Preceding.Add(wordInfos[beforeIndex]);
}
for (int afterIndex = wordIndex; afterIndex < tokens.Length; afterIndex++)
{
wordInfos[wordIndex].Following.Add(wordInfos[afterIndex]);
}
}
dbContext.SaveChanges();
}
public void RegisterListing(ProductListing productListing, string shopName)
{
ProductListingInfo productListingInfo =
(from info in dbContext.ProductListingInfos
where info.ProductUrl.Equals(productListing.URL)
select info).SingleOrDefault() ?? new ProductListingInfo();
if (productListingInfo.Hits == 0)
{
dbContext.Add(productListingInfo);
}
productListingInfo.ShopName = shopName;
productListingInfo.ProductName = productListing.Name;
productListingInfo.ProductUrl = productListing.URL;
productListingInfo.LastUpdated = DateTime.UtcNow;
productListingInfo.Hits += 1;
dbContext.SaveChanges();
}
}
}

View File

@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Castle.Core.Logging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveSearchManager : ISearchManager
{
private ILogger<LiveSearchManager> logger;
private SearchOptions searchOptions;
private IShopManager shopManager;
private IMetricsManager metricsManager;
public LiveSearchManager(IMetricsManager metricsManager, IShopManager shopManager, IConfiguration configuration, ILogger<LiveSearchManager> logger)
{
this.logger = logger;
this.metricsManager = metricsManager;
this.shopManager = shopManager;
this.searchOptions = configuration.GetSection(SearchOptions.Search).Get<SearchOptions>();
}
public IEnumerable<ProductListing> Search(string query, SearchOutline searchOutline)
{
if (string.IsNullOrWhiteSpace(query)) throw new ArgumentException($"Query \"{query}\" is null or whitepsace.");
if (searchOutline == null) throw new ArgumentNullException("searchOutline");
List<ProductListing> results = new List<ProductListing>();
metricsManager.RegisterSearchQuery(query);
logger.LogDebug("Searching for \"{0}\".", query);
foreach (string shopName in shopManager.GetAllShopNames())
{
if (searchOutline.Enabled[shopName])
{
logger.LogDebug("Checking \"{0}\".", shopName);
int amount = 0;
foreach (ProductListing product in shopManager.GetShop(shopName).Search(query, searchOutline.Filters))
{
if (searchOutline.Filters.Validate(product))
{
amount += 1;
metricsManager.RegisterListing(product, shopName);
results.Add(product);
}
if (amount >= searchOptions.MaxResults) break;
}
logger.LogDebug("Found {0} listings that satisfy the search filters from {1}.", amount, shopName);
}
}
return results;
}
}
}

View File

@ -1,24 +1,30 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search; using Props.Models.Search;
using Props.Options; using Props.Options;
using Props.Shop.Framework; using Props.Shop.Framework;
namespace Props.Services.Modules namespace Props.Services.Modules
{ {
public class LocalShopManager : IShopManager public class ModularShopManager : IShopManager
{ {
private ILogger<LocalShopManager> logger; private ILogger<ModularShopManager> logger;
private Dictionary<string, IShop> shops; private Dictionary<string, IShop> shops;
private ModulesOptions options; private ModulesOptions options;
private IConfiguration configuration; private IConfiguration configuration;
public LocalShopManager(IConfiguration configuration, ILogger<LocalShopManager> logger) private bool disposedValue;
public ModularShopManager(IConfiguration configuration, ILogger<ModularShopManager> logger)
{ {
this.configuration = configuration; this.configuration = configuration;
this.logger = logger; this.logger = logger;
@ -34,35 +40,25 @@ namespace Props.Services.Modules
} }
} }
public IEnumerable<string> AvailableShops() public IEnumerable<string> GetAllShopNames()
{ {
return shops.Keys; return shops.Keys;
} }
public async Task<IList<ProductListing>> Search(string query, SearchOutline searchOutline)
public IShop GetShop(string name)
{ {
List<ProductListing> results = new List<ProductListing>(); return shops[name];
foreach (string shopName in shops.Keys)
{
if (!searchOutline.Disabled[shopName])
{
int amount = 0;
await foreach (ProductListing product in shops[shopName].Search(query, searchOutline.Filters))
{
if (searchOutline.Filters.Validate(product))
{
amount += 1;
results.Add(product);
} }
if (amount >= options.MaxResults) break;
} public IEnumerable<IShop> GetAllShops()
} {
} return shops.Values;
return results;
} }
private IEnumerable<IShop> LoadShops(string shopsDir, string shopRegex, bool recursiveLoad) private IEnumerable<IShop> LoadShops(string shopsDir, string shopRegex, bool recursiveLoad)
{ {
Stack<Task> asyncInitTasks = new Stack<Task>();
Stack<string> directories = new Stack<string>(); Stack<string> directories = new Stack<string>();
directories.Push(shopsDir); directories.Push(shopsDir);
string currentDirectory = null; string currentDirectory = null;
@ -90,7 +86,11 @@ namespace Props.Services.Modules
IShop shop = Activator.CreateInstance(type) as IShop; IShop shop = Activator.CreateInstance(type) as IShop;
if (shop != null) if (shop != null)
{ {
// TODO: load persisted shop data.
shop.Initialize(null);
asyncInitTasks.Push(shop.InitializeAsync(null));
success += 1; success += 1;
logger.LogDebug("Loaded \"{0}\".", shop.ShopName);
yield return shop; yield return shop;
} }
} }
@ -102,6 +102,32 @@ namespace Props.Services.Modules
} }
} }
} }
logger.LogDebug("Waiting for all shops to finish asynchronous initialization.");
Task.WaitAll(asyncInitTasks.ToArray());
logger.LogDebug("All shops finished asynchronous initialization.");
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
foreach (string shopName in shops.Keys)
{
// TODO: Get shop data to persist.
shops[shopName].Dispose();
}
}
disposedValue = true;
}
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
} }
} }
} }

View File

@ -55,7 +55,9 @@ namespace Props
services.AddRazorPages(); services.AddRazorPages();
services.AddSingleton(typeof(IContentManager<>), typeof(CachedContentManager<>)); services.AddSingleton(typeof(IContentManager<>), typeof(CachedContentManager<>));
services.AddSingleton<IShopManager, LocalShopManager>(); services.AddSingleton<IShopManager, ModularShopManager>();
services.AddScoped<IMetricsManager, LiveMetricsManager>();
services.AddScoped<ISearchManager, LiveSearchManager>();
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -2,16 +2,9 @@
"DetailedErrors": true, "DetailedErrors": true,
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information"
} }
},
"webOptimizer": {
"enableCaching": false,
"enableMemoryCache": false,
"enableDiskCache": false,
"enableTagHelperBundling": false,
"allowEmptyBundle": false
} }
} }

View File

@ -12,9 +12,11 @@
"Modules": { "Modules": {
"ShopsDir": "./shops", "ShopsDir": "./shops",
"RecursiveLoad": "false", "RecursiveLoad": "false",
"MaxResults": "100",
"ShopRegex": "Props\\.Shop\\.." "ShopRegex": "Props\\.Shop\\.."
}, },
"Search": {
"MaxResults": 100
},
"Content": { "Content": {
"Dir": "./content" "Dir": "./content"
}, },

View File

@ -1,117 +1,76 @@
import Alpine from "alpinejs";
import { apiHttp } from "~/assets/js/services/http.js"; import { apiHttp } from "~/assets/js/services/http.js";
// All input fields. const startingSlide = "#quick-picks-slide";
let inputs = {
maxPriceEnabled: document.getElementById("max-price-enabled"),
maxShippingEnabled: document.getElementById("max-shipping-enabled"),
minRating: document.getElementById("min-rating"),
maxPrice: document.getElementById("max-price"),
maxShipping: document.getElementById("max-shipping"),
keepUnknownPurchases: document.getElementById("keep-unknown-purchases"),
keepUnknownReviews: document.getElementById("keep-unknown-reviews"),
keepUnknownShipping: document.getElementById("keep-unknown-shipping"),
keepUnrated: document.getElementById("keep-unrated"),
minPrice: document.getElementById("min-price"),
minPurchases: document.getElementById("min-purchases"),
minReviews: document.getElementById("min-reviews"),
shopToggles: {}
};
async function main() { function initInteractiveElements() {
setupInteractiveBehavior();
await setupInitialValues((await apiHttp.get("/Search/Default/Filters")).data);
await setupShopToggles((await apiHttp.get("/Search/Shops/Available")).data);
document.querySelector("#configuration .invisible").classList.remove("invisible"); // Load completed, show the UI.
}
function setupInteractiveBehavior() {
let configurationElem = document.getElementById("configuration");
let configurationToggle = document.getElementById("configuration-toggle"); let configurationToggle = document.getElementById("configuration-toggle");
let configurationElem = document.getElementById("configuration");
configurationElem.addEventListener("show.bs.collapse", function () { configurationElem.addEventListener("show.bs.collapse", function () {
configurationToggle.classList.add("active"); configurationToggle.classList.add("active");
}); });
configurationElem.addEventListener("hidden.bs.collapse", function () { configurationElem.addEventListener("hidden.bs.collapse", function () {
configurationToggle.classList.remove("active"); configurationToggle.classList.remove("active");
}); });
}
async function initConfigurationData() {
inputs.maxPriceEnabled.addEventListener("change", function () { const givenConfig = (await apiHttp.get("/SearchOutline/Filters")).data;
inputs.maxPrice.disabled = !this.checked; const disabledShops = (await apiHttp.get("/SearchOutline/DisabledShops")).data;
const availableShops = (await apiHttp.get("/Search/Available")).data;
document.addEventListener("alpine:init", () => {
Alpine.data("configuration", () => {
const configuration = {
maxPriceEnabled: givenConfig.enableUpperPrice,
maxPrice: givenConfig.upperPrice,
minPrice: givenConfig.lowerPrice,
maxShippingEnabled: givenConfig.enableMaxShippingFee,
maxShipping: givenConfig.maxShippingFee,
keepUnknownShipping: givenConfig.keepUnknownShipping,
minRating: givenConfig.minRating * 100,
keepUnrated: givenConfig.keepUnrated,
minReviews: givenConfig.minReviews,
keepUnknownReviews: givenConfig.keepUnknownReviewCount,
keepUnknownPurchases: givenConfig.keepUnknownPurchaseCount,
minPurchases: givenConfig.minPurchases,
shops: {},
};
availableShops.forEach(shop => {
configuration.shops[shop] = !disabledShops.includes(shop);
}); });
return configuration;
inputs.maxShippingEnabled.addEventListener("change", function () {
inputs.maxShipping.disabled = !this.checked;
}); });
inputs.minRating.addEventListener("input", function () {
document.getElementById("min-rating-display").innerHTML = `Minimum rating: ${this.value}%`;
}); });
} }
async function setupInitialValues(filters) { function initSlides() {
inputs.maxShippingEnabled.checked = filters.enableMaxShippingFee; document.querySelectorAll("#content-pages > .selectors > .nav-item > button").forEach(tabElem => {
inputs.maxShippingEnabled.dispatchEvent(new Event("change")); tabElem.addEventListener("click", () => {
const destUrl = new URL(tabElem.getAttribute("data-bs-target"), window.location.href);
if (location.href === destUrl.href) return;
history.pushState({}, document.title, destUrl);
});
});
const goTo = () => {
const match = location.href.match("(#[\\w-]+)");
const idAnchor = match && match[1] ? match[1] : startingSlide;
document.querySelector("#content-pages > .selectors > .nav-item > .active")?.classList.remove("active");
document.querySelector("#content-pages > .multipage-slides > .active.show")?.classList.remove("active", "show");
document.querySelector(`#content-pages > .selectors > .nav-item > [data-bs-target="${idAnchor}"]`).classList.add("active");
document.querySelector(`#content-pages > .multipage-slides > ${idAnchor}`).classList.add("active", "show");
};
window.addEventListener("popstate", goTo);
goTo();
inputs.maxPriceEnabled.checked = filters.enableUpperPrice; require("bootstrap/js/dist/tab.js");
inputs.maxPriceEnabled.dispatchEvent(new Event("change")); document.querySelector("#content-pages").classList.remove("invisible");
inputs.keepUnknownPurchases.checked = filters.keepUnknownPurchaseCount;
inputs.keepUnknownPurchases.dispatchEvent(new Event("change"));
inputs.keepUnknownReviews.checked = filters.keepUnknownReviewCount;
inputs.keepUnknownReviews.dispatchEvent(new Event("change"));
inputs.keepUnknownShipping.checked = filters.keepUnknownShipping;
inputs.keepUnknownShipping.dispatchEvent(new Event("change"));
inputs.keepUnrated.checked = filters.keepUnrated;
inputs.keepUnrated.dispatchEvent(new Event("change"));
inputs.minPrice.value = filters.lowerPrice;
inputs.minPrice.dispatchEvent(new Event("change"));
inputs.maxShipping.value = filters.maxShippingFee;
inputs.maxShipping.dispatchEvent(new Event("change"));
inputs.minPurchases.value = filters.minPurchases;
inputs.minPurchases.dispatchEvent(new Event("change"));
inputs.minRating.value = filters.minRating * 100;
inputs.minRating.dispatchEvent(new Event("input"));
inputs.minReviews.value = filters.minReviews;
inputs.minReviews.dispatchEvent(new Event("change"));
inputs.maxPrice.value = filters.upperPrice;
inputs.maxPrice.dispatchEvent(new Event("change"));
} }
async function setupShopToggles(availableShops) { async function main() {
let disabledShops = (await apiHttp.get("/Search/Default/DisabledShops")).data; initInteractiveElements();
let shopsElem = document.getElementById("shop-checkboxes"); await initConfigurationData();
availableShops.forEach(shopName => { initSlides();
let id = `${shopName}-enabled`; Alpine.start();
let shopLabelElem = document.createElement("label");
shopLabelElem.classList.add("form-check-label");
shopLabelElem.htmlFor = id;
shopLabelElem.innerHTML = `Enable ${shopName}`;
let shopCheckboxElem = document.createElement("input");
shopCheckboxElem.classList.add("form-check-input");
shopCheckboxElem.type = "checkbox";
shopCheckboxElem.id = id;
shopCheckboxElem.checked = !disabledShops.includes(shopName);
inputs.shopToggles[shopName] = shopCheckboxElem;
let shopToggleElem = document.createElement("div");
shopToggleElem.classList.add("form-check");
shopToggleElem.appendChild(shopCheckboxElem);
shopToggleElem.appendChild(shopLabelElem);
shopsElem.appendChild(shopToggleElem);
});
} }
main(); main();
// TODO: Implement search.

View File

@ -17,10 +17,11 @@ header > nav {
&.active { &.active {
@include themer.themed { @include themer.themed {
color: themer.color-of("navbar-active"); color: themer.color-of("navbar-active");
border-color: themer.color-of("navbar-active"); border-bottom-color: themer.color-of("navbar-active");
} }
padding-bottom: 2px;
border-bottom-style: solid; border-bottom-style: solid;
border-bottom-width: 1px; border-bottom-width: 2px;
} }
} }
@ -82,26 +83,32 @@ footer {
} }
.concise { .concise {
@extend .container;
max-width: 630px; max-width: 630px;
width: inherit; width: inherit;
} }
.less-concise { .less-concise {
@extend .container;
max-width: 720px; max-width: 720px;
width: inherit; width: inherit;
} }
hr.concise { hr {
@extend .my-3; &.concise {
@include themer.themed { @extend .my-2;
color: themer.color-of("special");
}
width: 15%; width: 15%;
max-width: 160px; max-width: 160px;
min-width: 32px; min-width: 32px;
margin-left: auto;
margin-right: auto;
height: 2px; height: 2px;
}
&.less-concise {
@extend .my-2;
width: 30%;
max-width: 270px;
min-width: 32px;
height: 2px;
}
} }
html { html {
@ -119,3 +126,61 @@ body {
color: themer.color-of("text"); color: themer.color-of("text");
} }
} }
.text-muted {
@include themer.themed {
color: themer.color-of("muted") !important;
}
}
.multipage {
display: flex;
flex-direction: column;
flex-grow: 1;
.multipage-slides, .multipage-slides > .multipage-slide.active {
display: flex;
flex-direction: column;
flex-grow: 1;
}
.multipage-slide {
.multipage-content {
@extend .container;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.multipage-title {
@extend .less-concise;
text-align: center;
}
}
.nav-pills.selectors {
display: flex;
justify-content: center;
margin-bottom: 1rem;
button[type="button"] {
margin-right: 5px;
margin-left: 5px;
opacity: 0.4;
border-style: none;
font-size: 1.5rem;
min-width: 30px;
width: auto;
height: auto;
background-color: transparent;
background-clip: border-box;
@include themer.themed {
border-bottom: 2px solid themer.color-of("text");
}
border-bottom-style: none;
&.active {
opacity: 1;
}
}
}
}

View File

@ -1349,6 +1349,19 @@
"integrity": "sha512-8h7k1YgQKxKXWckzFCMfsIwn0Y61UK6tlD6y2lOb3hTOIMlK3t9/QwHOhc81TwU+RMf0As5fj7NPjroERCnejQ==", "integrity": "sha512-8h7k1YgQKxKXWckzFCMfsIwn0Y61UK6tlD6y2lOb3hTOIMlK3t9/QwHOhc81TwU+RMf0As5fj7NPjroERCnejQ==",
"dev": true "dev": true
}, },
"@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"requires": {
"@vue/shared": "3.1.5"
}
},
"@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
},
"@webassemblyjs/ast": { "@webassemblyjs/ast": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@ -1558,6 +1571,14 @@
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true "dev": true
}, },
"alpinejs": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.2.2.tgz",
"integrity": "sha512-XkQKikB4tJTLIQuRUeF86CZnvmAKhjGzw5lmMri+7MTQzz77DTetuOqldBWjEgdJ/DOExXuiM57rQwWfBPdMPA==",
"requires": {
"@vue/reactivity": "^3.0.2"
}
},
"ansi-colors": { "ansi-colors": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
@ -2411,14 +2432,6 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==" "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
}, },
"framesync": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/framesync/-/framesync-5.3.0.tgz",
"integrity": "sha512-oc5m68HDO/tuK2blj7ZcdEBRx3p1PjrgHazL8GYEpvULhrtGIFbQArN6cQS2QhW8mitffaB+VYzMjDqBxxQeoA==",
"requires": {
"tslib": "^2.1.0"
}
},
"fs.realpath": { "fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -2554,11 +2567,6 @@
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
"dev": true "dev": true
}, },
"hey-listen": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz",
"integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="
},
"human-signals": { "human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@ -3125,17 +3133,6 @@
"find-up": "^4.0.0" "find-up": "^4.0.0"
} }
}, },
"popmotion": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/popmotion/-/popmotion-9.4.0.tgz",
"integrity": "sha512-FGnHjc8iDMrAwuZhka8eNx0yzcaufDqyZzW9vjJebRuC6BryR5ICyBmUH+wCgUuuaFSSU4r6oT2WtnbnDGcr3g==",
"requires": {
"framesync": "5.3.0",
"hey-listen": "^1.0.8",
"style-value-types": "4.1.4",
"tslib": "^2.1.0"
}
},
"postcss": { "postcss": {
"version": "8.3.6", "version": "8.3.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz",
@ -3588,15 +3585,6 @@
"integrity": "sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg==", "integrity": "sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg==",
"dev": true "dev": true
}, },
"style-value-types": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-value-types/-/style-value-types-4.1.4.tgz",
"integrity": "sha512-LCJL6tB+vPSUoxgUBt9juXIlNJHtBMy8jkXzUJSBzeHWdBu6lhzHqCvLVkXFGsFIlNa2ln1sQHya/gzaFmB2Lg==",
"requires": {
"hey-listen": "^1.0.8",
"tslib": "^2.1.0"
}
},
"supports-color": { "supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -3700,11 +3688,6 @@
"is-number": "^7.0.0" "is-number": "^7.0.0"
} }
}, },
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
},
"type-check": { "type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -30,10 +30,10 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.14.8", "@babel/runtime": "^7.14.8",
"alpinejs": "^3.2.2",
"axios": "^0.21.1", "axios": "^0.21.1",
"bootstrap": "^5.0.2", "bootstrap": "^5.0.2",
"bootstrap-icons": "^1.5.0", "bootstrap-icons": "^1.5.0",
"popmotion": "^9.4.0",
"simplebar": "^5.3.5" "simplebar": "^5.3.5"
} }
} }

View File

@ -0,0 +1,84 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v5.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v5.0": {
"Props.Shop.Adafruit/1.0.0": {
"dependencies": {
"FuzzySharp": "2.0.2",
"Newtonsoft.Json": "13.0.1",
"Props.Shop.Framework": "1.0.0",
"System.Linq.Async": "5.0.0"
},
"runtime": {
"Props.Shop.Adafruit.dll": {}
}
},
"FuzzySharp/2.0.2": {
"runtime": {
"lib/netcoreapp2.1/FuzzySharp.dll": {
"assemblyVersion": "1.0.4.0",
"fileVersion": "1.0.4.0"
}
}
},
"Newtonsoft.Json/13.0.1": {
"runtime": {
"lib/netstandard2.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.1.25517"
}
}
},
"System.Linq.Async/5.0.0": {
"runtime": {
"lib/netcoreapp3.1/System.Linq.Async.dll": {
"assemblyVersion": "5.0.0.0",
"fileVersion": "5.0.0.1"
}
}
},
"Props.Shop.Framework/1.0.0": {
"runtime": {
"Props.Shop.Framework.dll": {}
}
}
}
},
"libraries": {
"Props.Shop.Adafruit/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"FuzzySharp/2.0.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-sBKqWxw3g//peYxDZ8JipRlyPbIyBtgzqBVA5GqwHVeqtIrw75maGXAllztf+1aJhchD+drcQIgf2mFho8ZV8A==",
"path": "fuzzysharp/2.0.2",
"hashPath": "fuzzysharp.2.0.2.nupkg.sha512"
},
"Newtonsoft.Json/13.0.1": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==",
"path": "newtonsoft.json/13.0.1",
"hashPath": "newtonsoft.json.13.0.1.nupkg.sha512"
},
"System.Linq.Async/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-cPtIuuH8TIjVHSi2ewwReWGW1PfChPE0LxPIDlfwVcLuTM9GANFTXiMB7k3aC4sk3f0cQU25LNKzx+jZMxijqw==",
"path": "system.linq.async/5.0.0",
"hashPath": "system.linq.async.5.0.0.nupkg.sha512"
},
"Props.Shop.Framework/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}

Binary file not shown.