Basic search outline config UI implemented.

This commit is contained in:
Harrison Deng 2021-08-17 02:59:01 -05:00
parent 8a1e5aca15
commit c6b8ca523b
25 changed files with 1047 additions and 520 deletions

View File

@ -123,8 +123,13 @@ namespace Props.Shop.Adafruit
if (workspaceDir != null) if (workspaceDir != null)
{ {
logger.LogDebug("Saving data in \"{0}\"...", workspaceDir); logger.LogDebug("Saving data in \"{0}\"...", workspaceDir);
string configurationPath = Path.Combine(workspaceDir, Configuration.FILE_NAME);
File.Delete(configurationPath);
await File.WriteAllTextAsync(Path.Combine(workspaceDir, Configuration.FILE_NAME), JsonSerializer.Serialize(configuration)); await File.WriteAllTextAsync(Path.Combine(workspaceDir, Configuration.FILE_NAME), JsonSerializer.Serialize(configuration));
using (Stream fileStream = File.OpenWrite(Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME)))
string productListingCachePath = Path.Combine(workspaceDir, ProductListingCacheData.FILE_NAME);
File.Delete(productListingCachePath);
using (Stream fileStream = File.OpenWrite(productListingCachePath))
{ {
await JsonSerializer.SerializeAsync(fileStream, new ProductListingCacheData(await searchManager.ProductListingManager.ProductListings)); await JsonSerializer.SerializeAsync(fileStream, new ProductListingCacheData(await searchManager.ProductListingManager.ProductListings));
} }

View File

@ -5,12 +5,12 @@ namespace Props.Shop.Framework
public class Filters public class Filters
{ {
public Currency Currency { get; set; } = Currency.CAD; public Currency Currency { get; set; } = Currency.CAD;
private float minRatingNormalized; private float minRatingNormalized = 0.8f;
public int MinRating public int MinRating
{ {
get get
{ {
return (int)(minRatingNormalized * 100); return (int)(minRatingNormalized * 100f);
} }
set set
{ {
@ -38,7 +38,7 @@ namespace Props.Shop.Framework
public bool KeepUnknownPurchaseCount { get; set; } = true; public bool KeepUnknownPurchaseCount { get; set; } = true;
public int MinReviews { get; set; } public int MinReviews { get; set; }
public bool KeepUnknownReviewCount { get; set; } = true; public bool KeepUnknownReviewCount { get; set; } = true;
public bool EnableMaxShippingFee { get; set; } public bool EnableMaxShipping { get; set; }
private int maxShippingFee; private int maxShippingFee;
public int MaxShippingFee public int MaxShippingFee
@ -49,7 +49,7 @@ namespace Props.Shop.Framework
} }
set set
{ {
if (EnableMaxShippingFee) maxShippingFee = value; if (EnableMaxShipping) maxShippingFee = value;
} }
} }
public bool KeepUnknownShipping { get; set; } = true; public bool KeepUnknownShipping { get; set; } = true;
@ -72,7 +72,7 @@ namespace Props.Shop.Framework
KeepUnknownPurchaseCount == other.KeepUnknownPurchaseCount && KeepUnknownPurchaseCount == other.KeepUnknownPurchaseCount &&
MinReviews == other.MinReviews && MinReviews == other.MinReviews &&
KeepUnknownReviewCount == other.KeepUnknownReviewCount && KeepUnknownReviewCount == other.KeepUnknownReviewCount &&
EnableMaxShippingFee == other.EnableMaxShippingFee && EnableMaxShipping == other.EnableMaxShipping &&
MaxShippingFee == other.MaxShippingFee && MaxShippingFee == other.MaxShippingFee &&
KeepUnknownShipping == other.KeepUnknownShipping; KeepUnknownShipping == other.KeepUnknownShipping;
@ -96,7 +96,7 @@ namespace Props.Shop.Framework
public bool Validate(ProductListing listing) public bool Validate(ProductListing listing)
{ {
if (listing.Shipping == null && !KeepUnknownShipping || (EnableMaxShippingFee && listing.Shipping > MaxShippingFee)) return false; if (listing.Shipping == null && !KeepUnknownShipping || (EnableMaxShipping && listing.Shipping > MaxShippingFee)) return false;
float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0; float shippingDifference = listing.Shipping != null ? listing.Shipping.Value : 0;
if (!(listing.LowerPrice + shippingDifference >= LowerPrice && (!EnableUpperPrice || listing.UpperPrice + shippingDifference <= UpperPrice))) return false; if (!(listing.LowerPrice + shippingDifference >= LowerPrice && (!EnableUpperPrice || listing.UpperPrice + shippingDifference <= UpperPrice))) return false;
if ((listing.Rating == null && !KeepUnrated) && MinRating > (listing.Rating == null ? 0 : listing.Rating)) return false; if ((listing.Rating == null && !KeepUnrated) && MinRating > (listing.Rating == null ? 0 : listing.Rating)) return false;

View File

@ -1,24 +1,38 @@
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Props.Models.Search; using Props.Models.Search;
using Props.Services.Modules; using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Controllers namespace Props.Controllers
{ {
public class SearchController : ApiControllerBase public class SearchController : ApiControllerBase
{ {
private SearchOutline defaultOutline = new SearchOutline(); private SearchOutline defaultOutline = new SearchOutline();
IShopManager shopManager; ISearchManager searchManager;
public SearchController(IShopManager shopManager) public SearchController(ISearchManager searchManager)
{ {
this.shopManager = shopManager; this.searchManager = searchManager;
} }
[HttpGet] [HttpGet]
[Route("Available")] [Route("AvailableShops")]
public IActionResult GetAvailableShops() public async Task<IActionResult> GetAvailableShops()
{ {
return Ok(shopManager.GetAllShopNames()); return Ok(await searchManager.ShopManager.GetAllShopNames());
}
[HttpGet]
[Route("SearchShops/{search}/")]
public async Task<IActionResult> GetSearch(string searchQuery, [FromQuery] SearchOutline searchOutline)
{
if (searchQuery == null) return BadRequest();
return Ok(await searchManager.Search(searchQuery, searchOutline));
} }
} }
} }

View File

@ -1,37 +1,211 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Props.Data;
using Props.Models.Search; using Props.Models.Search;
using Props.Services.Modules; using Props.Models.User;
using Props.Shop.Framework;
namespace Props.Controllers namespace Props.Controllers
{ {
public class SearchOutlineController : ApiControllerBase public class SearchOutlineController : ApiControllerBase
{ {
private SearchOutline defaultOutline = new SearchOutline(); private ApplicationDbContext dbContext;
private UserManager<ApplicationUser> userManager;
public SearchOutlineController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext)
public SearchOutlineController()
{ {
this.userManager = userManager;
this.dbContext = dbContext;
}
[HttpDelete]
[Authorize]
[Route("{name:required}")]
public async Task<IActionResult> DeleteSearchOutline(string name)
{
if (string.IsNullOrEmpty(name))
{
return BadRequest();
}
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutlinePreferences searchOutlinePrefs = user.searchOutlinePreferences;
searchOutlinePrefs.SearchOutlines.Remove(searchOutlinePrefs.SearchOutlines.Single((outline) => name.Equals(outline.Name)));
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPost]
[Authorize]
[Route("{name:required}")]
public async Task<IActionResult> PostSearchOutline(string name)
{
if (string.IsNullOrEmpty(name)) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = user.searchOutlinePreferences.SearchOutlines.SingleOrDefault((outline) => name.Equals(outline.Name));
if (searchOutline != null) return BadRequest();
searchOutline = new SearchOutline();
searchOutline.Name = name;
user.searchOutlinePreferences.SearchOutlines.Add(searchOutline);
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{name:required}/Filters")]
public async Task<IActionResult> PutFilters(string name, Filters filters)
{
if (string.IsNullOrEmpty(name)) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = await GetSearchOutlineByName(name);
if (searchOutline == null) return BadRequest();
searchOutline.Filters = filters;
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{outlineName:required}/DisabledShops")]
public async Task<IActionResult> PutShopSelection(string outlineName, ISet<string> disabledShops)
{
if (string.IsNullOrEmpty(outlineName)) return BadRequest();
if (disabledShops == null) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline searchOutline = await GetSearchOutlineByName(outlineName);
if (searchOutline == null) return BadRequest();
searchOutline.DisabledShops.Clear();
searchOutline.DisabledShops.UnionWith(disabledShops);
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{oldName:required}/Name/{newName:required}")]
public async Task<IActionResult> PutName(string oldName, string newName)
{
if (oldName == newName) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
SearchOutline outline = await GetSearchOutlineByName(oldName);
if (outline == null) return BadRequest();
if (user.searchOutlinePreferences.SearchOutlines.Any((outline) => outline.Name.Equals(newName))) return BadRequest();
outline.Name = newName;
if (user.searchOutlinePreferences.NameOfLastUsed == oldName)
{
user.searchOutlinePreferences.NameOfLastUsed = newName;
}
await userManager.UpdateAsync(user);
return NoContent();
}
[HttpPut]
[Authorize]
[Route("{name:required}/LastUsed")]
public async Task<IActionResult> PutLastUsed(string name)
{
SearchOutline outline = await GetSearchOutlineByName(name);
if (outline == null) return BadRequest();
ApplicationUser user = await userManager.GetUserAsync(User);
user.searchOutlinePreferences.NameOfLastUsed = name;
await userManager.UpdateAsync(user);
return NoContent();
} }
[HttpGet] [HttpGet]
[Route("Filters")] [Authorize]
public IActionResult GetFilters() [Route("{name:required}/Filters")]
public async Task<IActionResult> GetFilters(string name)
{ {
return Ok(defaultOutline.Filters); Filters filters = (await GetSearchOutlineByName(name))?.Filters;
if (filters == null) return BadRequest();
return Ok(filters);
} }
[HttpGet] [HttpGet]
[Route("DisabledShops")] [Authorize]
public IActionResult GetDisabledShops() [Route("{name:required}/DisabledShops")]
public async Task<IActionResult> GetDisabledShops(string name)
{ {
return Ok(defaultOutline.Enabled); SearchOutline searchOutline = await GetSearchOutlineByName(name);
if (searchOutline == null)
{
return BadRequest();
}
return Ok(searchOutline.DisabledShops);
} }
[HttpGet] [HttpGet]
[Route("SearchOutlineName")] [Authorize]
public IActionResult GetSearchOutlineName() [Route("Names")]
public async Task<IActionResult> GetSearchOutlineNames()
{ {
return Ok(defaultOutline.Name); ApplicationUser user = await userManager.GetUserAsync(User);
return Ok(user.searchOutlinePreferences.SearchOutlines.Select((outline, Index) => outline.Name));
}
[HttpGet]
[Authorize]
[Route("LastUsed")]
public async Task<IActionResult> GetLastSearchOutlineName()
{
SearchOutline searchOutline = await GetLastUsedSearchOutline();
return Ok(searchOutline?.Name);
}
[HttpGet]
[Route("DefaultDisabledShops")]
public IActionResult GetDefaultDisabledShops()
{
return Ok(new SearchOutline.ShopSelector());
}
[HttpGet]
[Route("DefaultFilters")]
public IActionResult GetDefaultFilter()
{
return Ok(new Filters());
}
[HttpGet]
[Route("DefaultName")]
public async Task<IActionResult> GetDefaultName()
{
string nameTemplate = "Search Outline {0}";
if (User.Identity.IsAuthenticated)
{
ApplicationUser user = await userManager.GetUserAsync(User);
int number = user.searchOutlinePreferences.SearchOutlines.Count;
string name = null;
do
{
name = string.Format(nameTemplate, number);
number += 1;
} while (user.searchOutlinePreferences.SearchOutlines.Any((outline) => name.Equals(outline.Name)));
return Ok(name);
}
return Ok("Search Outline");
}
private async Task<SearchOutline> GetLastUsedSearchOutline()
{
if (!User.Identity.IsAuthenticated) return null;
ApplicationUser user = await userManager.GetUserAsync(User);
return user.searchOutlinePreferences.SearchOutlines.SingleOrDefault((outline) => outline.Name.Equals(user.searchOutlinePreferences.NameOfLastUsed));
}
private async Task<SearchOutline> GetSearchOutlineByName(string name)
{
if (name == null) throw new ArgumentNullException("name");
if (!User.Identity.IsAuthenticated) return null;
ApplicationUser user = await userManager.GetUserAsync(User);
return user.searchOutlinePreferences.SearchOutlines.SingleOrDefault(outline => outline.Name.Equals(name));
} }
} }
} }

View File

@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
namespace Props.Controllers
{
public class UserController : ApiControllerBase
{
[HttpGet]
[Route("LoggedIn")]
public IActionResult GetLoggedIn()
{
return Ok(User.Identity.IsAuthenticated);
}
}
}

View File

@ -17,7 +17,7 @@ namespace Props.Data
{ {
public class ApplicationDbContext : IdentityDbContext<ApplicationUser> public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{ {
public DbSet<QueryWordInfo> Keywords { get; set; } public DbSet<QueryWordInfo> QueryWords { get; set; }
public DbSet<ProductListingInfo> ProductListingInfos { get; set; } public DbSet<ProductListingInfo> ProductListingInfos { get; set; }
@ -43,14 +43,14 @@ namespace Props.Data
); );
modelBuilder.Entity<SearchOutline>() modelBuilder.Entity<SearchOutline>()
.Property(e => e.Enabled) .Property(e => e.DisabledShops)
.HasConversion( .HasConversion(
v => JsonSerializer.Serialize(v, null), v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null), v => JsonSerializer.Deserialize<SearchOutline.ShopSelector>(v, null),
new ValueComparer<SearchOutline.ShopsDisabled>( new ValueComparer<SearchOutline.ShopSelector>(
(a, b) => a.Equals(b), (a, b) => a.Equals(b),
c => c.GetHashCode(), c => c.GetHashCode(),
c => c.Copy() c => new SearchOutline.ShopSelector(c)
) )
); );

View File

@ -9,7 +9,7 @@ using Props.Data;
namespace Props.Data.Migrations namespace Props.Data.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20210809194646_InitialCreate")] [Migration("20210817042955_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -165,7 +165,6 @@ namespace Props.Data.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ProfileName") b.Property<string>("ProfileName")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
@ -214,7 +213,7 @@ namespace Props.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Keywords"); b.ToTable("QueryWords");
}); });
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -223,11 +222,7 @@ namespace Props.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId") b.Property<string>("DisabledShops")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Enabled")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -238,13 +233,11 @@ namespace Props.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId") b.Property<int>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.HasIndex("SearchOutlinePreferencesId"); b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline"); b.ToTable("SearchOutline");
@ -320,16 +313,14 @@ namespace Props.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId") b.Property<string>("ApplicationUserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.Property<string>("NameOfLastUsed")
.HasColumnType("TEXT");
b.HasIndex("ActiveSearchOutlineId"); b.HasKey("Id");
b.HasIndex("ApplicationUserId") b.HasIndex("ApplicationUserId")
.IsUnique(); .IsUnique();
@ -440,33 +431,23 @@ 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.SearchOutlinePreferences", "SearchOutlinePreferences")
.WithMany() .WithMany("SearchOutlines")
.HasForeignKey("ApplicationUserId") .HasForeignKey("SearchOutlinePreferencesId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null) b.Navigation("SearchOutlinePreferences");
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
}); });
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b => modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{ {
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser") b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences") .WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId") .HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });

View File

@ -46,20 +46,6 @@ namespace Props.Data.Migrations
table.PrimaryKey("PK_AspNetUsers", x => x.Id); table.PrimaryKey("PK_AspNetUsers", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "Keywords",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", 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( migrationBuilder.CreateTable(
name: "ProductListingInfos", name: "ProductListingInfos",
columns: table => new columns: table => new
@ -76,6 +62,20 @@ namespace Props.Data.Migrations
table.PrimaryKey("PK_ProductListingInfos", x => x.Id); table.PrimaryKey("PK_ProductListingInfos", x => x.Id);
}); });
migrationBuilder.CreateTable(
name: "QueryWords",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Word = table.Column<string>(type: "TEXT", nullable: false),
Hits = table.Column<uint>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryWords", x => x.Id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "AspNetRoleClaims", name: "AspNetRoleClaims",
columns: table => new columns: table => new
@ -211,7 +211,7 @@ namespace Props.Data.Migrations
.Annotation("Sqlite:Autoincrement", true), .Annotation("Sqlite:Autoincrement", true),
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false), ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
Order = table.Column<string>(type: "TEXT", nullable: false), Order = table.Column<string>(type: "TEXT", nullable: false),
ProfileName = table.Column<string>(type: "TEXT", nullable: false) ProfileName = table.Column<string>(type: "TEXT", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
@ -224,6 +224,26 @@ namespace Props.Data.Migrations
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),
NameOfLastUsed = table.Column<string>(type: "TEXT", 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);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "QueryWordInfoQueryWordInfo", name: "QueryWordInfoQueryWordInfo",
columns: table => new columns: table => new
@ -235,15 +255,15 @@ namespace Props.Data.Migrations
{ {
table.PrimaryKey("PK_QueryWordInfoQueryWordInfo", x => new { x.FollowingId, x.PrecedingId }); table.PrimaryKey("PK_QueryWordInfoQueryWordInfo", x => new { x.FollowingId, x.PrecedingId });
table.ForeignKey( table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_FollowingId", name: "FK_QueryWordInfoQueryWordInfo_QueryWords_FollowingId",
column: x => x.FollowingId, column: x => x.FollowingId,
principalTable: "Keywords", principalTable: "QueryWords",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
table.ForeignKey( table.ForeignKey(
name: "FK_QueryWordInfoQueryWordInfo_Keywords_PrecedingId", name: "FK_QueryWordInfoQueryWordInfo_QueryWords_PrecedingId",
column: x => x.PrecedingId, column: x => x.PrecedingId,
principalTable: "Keywords", principalTable: "QueryWords",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
@ -254,49 +274,22 @@ namespace Props.Data.Migrations
{ {
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), SearchOutlinePreferencesId = table.Column<int>(type: "INTEGER", nullable: false),
Name = 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),
Enabled = table.Column<string>(type: "TEXT", nullable: false), DisabledShops = table.Column<string>(type: "TEXT", nullable: false)
SearchOutlinePreferencesId = table.Column<int>(type: "INTEGER", nullable: true)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_SearchOutline", x => x.Id); table.PrimaryKey("PK_SearchOutline", x => x.Id);
table.ForeignKey( table.ForeignKey(
name: "FK_SearchOutline_AspNetUsers_ApplicationUserId", name: "FK_SearchOutline_SearchOutlinePreferences_SearchOutlinePreferencesId",
column: x => x.ApplicationUserId, column: x => x.SearchOutlinePreferencesId,
principalTable: "AspNetUsers", principalTable: "SearchOutlinePreferences",
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",
@ -351,50 +344,20 @@ namespace Props.Data.Migrations
column: "ApplicationUserId", column: "ApplicationUserId",
unique: true); unique: true);
migrationBuilder.CreateIndex(
name: "IX_SearchOutline_ApplicationUserId",
table: "SearchOutline",
column: "ApplicationUserId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_SearchOutline_SearchOutlinePreferencesId", name: "IX_SearchOutline_SearchOutlinePreferencesId",
table: "SearchOutline", table: "SearchOutline",
column: "SearchOutlinePreferencesId"); column: "SearchOutlinePreferencesId");
migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ActiveSearchOutlineId",
table: "SearchOutlinePreferences",
column: "ActiveSearchOutlineId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_SearchOutlinePreferences_ApplicationUserId", name: "IX_SearchOutlinePreferences_ApplicationUserId",
table: "SearchOutlinePreferences", table: "SearchOutlinePreferences",
column: "ApplicationUserId", column: "ApplicationUserId",
unique: true); 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");
@ -422,20 +385,20 @@ namespace Props.Data.Migrations
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "ResultsPreferences"); name: "ResultsPreferences");
migrationBuilder.DropTable(
name: "SearchOutline");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "AspNetRoles"); name: "AspNetRoles");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Keywords"); name: "QueryWords");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "SearchOutlinePreferences"); name: "SearchOutlinePreferences");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "SearchOutline"); name: "AspNetUsers");
} }
} }
} }

View File

@ -163,7 +163,6 @@ namespace Props.Data.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("ProfileName") b.Property<string>("ProfileName")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
@ -212,7 +211,7 @@ namespace Props.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Keywords"); b.ToTable("QueryWords");
}); });
modelBuilder.Entity("Props.Models.Search.SearchOutline", b => modelBuilder.Entity("Props.Models.Search.SearchOutline", b =>
@ -221,11 +220,7 @@ namespace Props.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId") b.Property<string>("DisabledShops")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Enabled")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -236,13 +231,11 @@ namespace Props.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("SearchOutlinePreferencesId") b.Property<int>("SearchOutlinePreferencesId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.HasIndex("SearchOutlinePreferencesId"); b.HasIndex("SearchOutlinePreferencesId");
b.ToTable("SearchOutline"); b.ToTable("SearchOutline");
@ -318,16 +311,14 @@ namespace Props.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("ActiveSearchOutlineId")
.HasColumnType("INTEGER");
b.Property<string>("ApplicationUserId") b.Property<string>("ApplicationUserId")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.HasKey("Id"); b.Property<string>("NameOfLastUsed")
.HasColumnType("TEXT");
b.HasIndex("ActiveSearchOutlineId"); b.HasKey("Id");
b.HasIndex("ApplicationUserId") b.HasIndex("ApplicationUserId")
.IsUnique(); .IsUnique();
@ -438,33 +429,23 @@ 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.SearchOutlinePreferences", "SearchOutlinePreferences")
.WithMany() .WithMany("SearchOutlines")
.HasForeignKey("ApplicationUserId") .HasForeignKey("SearchOutlinePreferencesId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Props.Models.User.SearchOutlinePreferences", null) b.Navigation("SearchOutlinePreferences");
.WithMany("SearchOutlines")
.HasForeignKey("SearchOutlinePreferencesId");
b.Navigation("ApplicationUser");
}); });
modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b => modelBuilder.Entity("Props.Models.User.SearchOutlinePreferences", b =>
{ {
b.HasOne("Props.Models.Search.SearchOutline", "ActiveSearchOutline")
.WithMany()
.HasForeignKey("ActiveSearchOutlineId");
b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser") b.HasOne("Props.Models.User.ApplicationUser", "ApplicationUser")
.WithOne("searchOutlinePreferences") .WithOne("searchOutlinePreferences")
.HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId") .HasForeignKey("Props.Models.User.SearchOutlinePreferences", "ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("ActiveSearchOutline");
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });

View File

@ -11,53 +11,45 @@ namespace Props.Models.Search
{ {
public int Id { get; set; } public int Id { get; set; }
[Required] public int SearchOutlinePreferencesId { get; set; }
public string ApplicationUserId { get; set; }
[Required] [Required]
public virtual ApplicationUser ApplicationUser { get; set; } public virtual SearchOutlinePreferences SearchOutlinePreferences { get; set; }
[Required] [Required]
public string Name { get; set; } = "Default"; public string Name { get; set; }
public Filters Filters { get; set; } = new Filters();
public Filters Filters { get; set; }
[Required] [Required]
public ShopsDisabled Enabled { get; set; } = new ShopsDisabled(); public ShopSelector DisabledShops { get; set; }
public sealed class ShopsDisabled : HashSet<string> public sealed class ShopSelector : HashSet<string>
{ {
public int TotalShops { get; set; }
public bool this[string name] public bool this[string name]
{ {
get get
{ {
return !this.Contains(name); return this.Contains(name);
} }
set set
{ {
if (value == false && TotalShops - Count <= 1) return;
if (value) if (value)
{ {
this.Remove(name); this.Add(name);
} }
else else
{ {
this.Add(name); this.Remove(name);
} }
} }
} }
public ShopsDisabled Copy() public ShopSelector()
{ {
ShopsDisabled copy = new ShopsDisabled();
copy.Union(this);
return copy;
} }
public bool IsShopToggleable(string shop) public ShopSelector(IEnumerable<string> disabledShops) : base(disabledShops)
{ {
return (!Contains(shop) && TotalShops - Count > 1) || Contains(shop);
} }
} }
@ -70,27 +62,27 @@ namespace Props.Models.Search
SearchOutline other = (SearchOutline)obj; SearchOutline other = (SearchOutline)obj;
return return
Id == other.Id && Id == other.Id &&
Name.Equals(other.Name) &&
Filters.Equals(other.Filters) && Filters.Equals(other.Filters) &&
Enabled.Equals(other.Enabled); DisabledShops.Equals(other.DisabledShops);
} }
public override int GetHashCode() public override int GetHashCode()
{ {
return HashCode.Combine(Id, Name); return HashCode.Combine(Id, Name, Filters, DisabledShops);
} }
public SearchOutline() public SearchOutline()
{ {
this.Name = "Default";
this.Filters = new Filters(); this.Filters = new Filters();
this.Enabled = new ShopsDisabled(); this.DisabledShops = new ShopSelector();
} }
public SearchOutline(string name, Filters filters, ShopsDisabled disabled) public SearchOutline(string name, Filters filters, ShopSelector disabled)
{ {
this.Name = name; this.Name = name;
this.Filters = filters; this.Filters = filters;
this.Enabled = disabled; this.DisabledShops = disabled;
} }
} }
} }

View File

@ -21,7 +21,6 @@ namespace Props.Models
[Required] [Required]
public IList<Category> Order { get; set; } public IList<Category> Order { get; set; }
[Required]
public string ProfileName { get; set; } public string ProfileName { get; set; }
public ResultsPreferences() public ResultsPreferences()

View File

@ -16,22 +16,19 @@ namespace Props.Models.User
public virtual ApplicationUser ApplicationUser { get; set; } public virtual ApplicationUser ApplicationUser { get; set; }
[Required] [Required]
public virtual ISet<SearchOutline> SearchOutlines { get; set; } public virtual IList<SearchOutline> SearchOutlines { get; set; }
[Required] public string NameOfLastUsed { get; set; }
public virtual SearchOutline ActiveSearchOutline { get; set; }
public SearchOutlinePreferences() public SearchOutlinePreferences()
{ {
SearchOutlines = new HashSet<SearchOutline>(); SearchOutlines = new List<SearchOutline>();
ActiveSearchOutline = new SearchOutline();
SearchOutlines.Add(ActiveSearchOutline);
} }
public SearchOutlinePreferences(ISet<SearchOutline> searchOutlines, SearchOutline activeSearchOutline) public SearchOutlinePreferences(List<SearchOutline> searchOutlines, string nameOfLastUsed)
{ {
this.SearchOutlines = searchOutlines; this.SearchOutlines = searchOutlines;
this.ActiveSearchOutline = activeSearchOutline; this.NameOfLastUsed = nameOfLastUsed;
} }
} }
} }

View File

@ -0,0 +1,9 @@
namespace Props.Options
{
public class MetricsOptions
{
public const string Metrics = "Metrics";
public int MaxQueryWords { get; set; }
public int MaxProductListings { get; set; }
}
}

View File

@ -8,253 +8,296 @@
ViewData["Specific"] = "Search"; ViewData["Specific"] = "Search";
} }
<form method="GET"> <div class="flex-grow-1 d-flex flex-column" x-data="search">
<div class="mt-4 mb-3 less-concise mx-auto"> <div class="mt-4 mb-3 less-concise">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control border-primary" placeholder="What are you looking for?" <input type="text" class="form-control border-primary" placeholder="What are you looking for?"
aria-label="Search" aria-describedby="search-btn" id="search-bar" value="@Model.SearchQuery" name="q"> aria-label="Search" aria-describedby="search-btn" id="search-bar" value="@Model.SearchQuery"
x-model="query">
<button class="btn btn-outline-secondary" type="button" id="configuration-toggle" data-bs-toggle="collapse" <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> data-bs-target="#configuration"><i class="bi bi-sliders"></i></button>
<button class="btn btn-primary" type="submit" id="search-btn">Search</button> <button class="btn btn-primary" id="search-btn" x-on:click="submitSearch" x-on:keyup="">Search</button>
</div> </div>
</div> </div>
<div class="collapse tear" id="configuration"> <div class="collapse tear" id="configuration">
<div class="container my-3"> <div class="d-flex">
<div class="d-flex"> <h1 class="my-3 display-2 mx-auto">
<h1 class="my-2 display-2 me-auto">Configuration</h1> <i class="bi bi-sliders"></i>
<button class="btn align-self-start" type="button" id="configuration-close" data-bs-toggle="collapse" Configuration
data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button> </h1>
</div> <button class="btn align-self-start m-3" type="button" id="configuration-close" data-bs-toggle="collapse"
<div class="row justify-content-md-center"> data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
<section class="col-lg px-4"> </div>
<h3>Price</h3> <div class="container">
<div class="mb-3"> <div class="row my-3">
<label for="max-price" class="form-label">Maximum Price</label> <div class="col-lg-3 px-2">
<div class="input-group"> <div class="row">
<div class="input-group-text"> <div class="col">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled" <h3>Search Outlines</h3>
checked="@Model.ActiveSearchOutline.Filters.EnableUpperPrice" </div>
name="ActiveSearchOutline.Filters.EnableUpperPrice"> <div class="col-auto" x-show="loggedIn">
<button class="btn" x-show="!creatingSearchOutline"
x-on:click="createSearchOutlineWithGeneratedName"
x-bind:disabled="creatingSearchOutline">
<i class="bi bi-plus-lg"></i>
</button>
<div x-show="creatingSearchOutline" class="spinner-border me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div> </div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-price"
value="@Model.ActiveSearchOutline.Filters.UpperPrice"
name="ActiveSearchOutline.Filters.UpperPrice">
<span class="input-group-text">.00</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="row px-3">
<label for="min-price" class="form-label">Minimum Price</label> <div style="max-height: 28em;" data-simplebar>
<div class="input-group"> <template x-for="(current, i) in searchOutlines">
<span class="input-group-text">$</span> <div class="clean-radio d-flex">
<input type="number" class="form-control" min="0" id="min-price" <input type="radio" x-bind:id="`${i}-selector`" name="search-outline"
value="@Model.ActiveSearchOutline.Filters.LowerPrice" x-bind:value="i" x-model="selectedSearchOutline"
name="ActiveSearchOutline.Filters.LowerPrice"> x-on:click="loadSearchOutline(current)" x-bind:disabled="!searchOutline.ready">
<span class="input-group-text">.00</span> <label class="flex-grow-1" x-bind:for="`${i}-selector`">
</div> <span class="me-auto" x-text="current"></span>
</div> </label>
<div class="mb-3"> <button class="btn m-1" x-show="loggedIn" x-on:click="deleteSearchOutline(current)"
<label for="max-shipping" class="form-label">Maximum Shipping Fee</label> x-bind:disabled="deletingSearchOutline || (searchOutlines.length < 2)">
<div class="input-group"> <i class="bi bi-trash"></i>
<div class="input-group-text"> </button>
<input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled" </div>
checked="@Model.ActiveSearchOutline.Filters.EnableMaxShippingFee" </template>
name="ActiveSearchOutline.Filters.EnableMaxShippingFee"> <div class="text-muted text-center my-3" x-show="!loggedIn">
<h3><i class="bi bi-box-arrow-in-right"></i></h3>
<p>This is where all your search outlines will show up. <a asp-area="Identity"
asp-page="/Account/Login">Sign in</a> to create search outlines!</p>
</div> </div>
<span class="input-group-text">$</span> <template x-if="(searchOutlines.length < 2) && (loggedIn)">
<input type="number" class="form-control" min="0" id="max-shipping" <div class="text-muted text-center my-3 p-3">
value="@Model.ActiveSearchOutline.Filters.MaxShippingFee" <p>Add more search outlines by clicking the <i class="bi bi-plus-lg"></i> above!</p>
name="ActiveSearchOutline.Filters.MaxShippingFee"> </div>
<span class="input-group-text">.00</span> </template>
</div> </div>
</div> </div>
<div class="mb-3"> </div>
<div class="form-check"> <div class="col-lg px-4">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping" <div class="row">
checked="@Model.ActiveSearchOutline.Filters.KeepUnknownShipping" <input class="title-input less-concise mx-4"
name="ActiveSearchOutline.Filters.KeepUnknownShipping"> x-bind:class="searchOutline.ready ? '' : 'invisible'" type="text"
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown Shipping</label> x-model="searchOutlines[selectedSearchOutline]" x-on:change="SearchOutlineNameChange"
</div> x-bind:disabled="(!loggedIn) || (changingName)">
</div> </div>
</section> <div class="row justify-content-md-center" x-bind:class="searchOutline.ready ? '' : 'invisible'">
<section class="col-lg px-4"> <section class="col-md px-3">
<h3>Metrics</h3> <h3>Price</h3>
<div class="mb-3"> <div class="mb-3">
<label for="min-purchases" class="form-label">Minimum Purchases</label> <label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group"> <div class="input-group">
<input type="number" class="form-control" min="0" id="min-purchases" <div class="input-group-text">
value="@Model.ActiveSearchOutline.Filters.MinPurchases" <input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
name="ActiveSearchOutline.Filters.MinPurchases"> x-model="searchOutline.filters.enableUpperPrice"
<span class="input-group-text">Purchases</span> x-on:change="searchOutlineChanged">
</div> </div>
</div> <span class="input-group-text">$</span>
<div class="mb-3"> <input type="number" class="form-control" min="0" id="max-price"
<div class="form-check"> x-model="searchOutline.filters.upperPrice"
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases" x-bind:disabled="!searchOutline.filters.enableUpperPrice"
checked="@Model.ActiveSearchOutline.Filters.KeepUnknownPurchaseCount" x-on:change="searchOutlineChanged" x-on:input="validateNumericalInputs">
name="ActiveSearchOutline.Filters.KeepUnknownPurchaseCount"> <span class="input-group-text">.00</span>
<label class="form-check-label" for="keep-unknown-purchases">Keep Unknown Purchases</label> </div>
</div>
</div>
<div class="mb-3">
<label for="min-reviews" class="form-label">Minimum Reviews</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-reviews"
value="@Model.ActiveSearchOutline.Filters.MinReviews"
name="ActiveSearchOutline.Filters.MinReviews">
<span class="input-group-text">Reviews</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews"
checked="@Model.ActiveSearchOutline.Filters.KeepUnknownReviewCount"
name="ActiveSearchOutline.Filters.KeepUnknownReviewCount">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number of
Reviews</label>
</div>
</div>
<div class="mb-1">
<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"
value="@Model.ActiveSearchOutline.Filters.MinRating"
name="ActiveSearchOutline.Filters.MinRating">
<div id="min-rating-display" class="form-text"></div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unrated"
checked="@Model.ActiveSearchOutline.Filters.KeepUnrated"
name="ActiveSearchOutline.Filters.KeepUnrated">
<label class="form-check-label" for="keep-unrated">Keep Unrated Items</label>
</div>
</div>
</section>
<section class="col-lg px-4">
<h3>Shops Enabled</h3>
<div class="mb-3 px-3" id="shop-checkboxes">
@foreach (string shopName in Model.SearchManager.ShopManager.GetAllShopNames())
{
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@($"{shopName}-enabled")"
checked="@Model.ActiveSearchOutline.Enabled[shopName]">
<label class="form-check-label" for="@($"{shopName}-enabled")">@shopName</label>
</div> </div>
} <div class="mb-3">
<label for="min-price" class="form-label">Minimum Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="min-price"
x-model="searchOutline.filters.lowerPrice" x-on:change="searchOutlineChanged"
x-on:input="validateNumericalInputs">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<label for="max-shipping" class="form-label">Maximum Shipping Fee</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-shipping-enabled"
x-model="searchOutline.filters.enableMaxShipping"
x-on:change="searchOutlineChanged">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-shipping"
x-model="searchOutline.filters.maxShippingFee"
x-bind:disabled="!searchOutline.filters.enableMaxShipping"
x-on:change="searchOutlineChanged" x-on:input="validateNumericalInputs">
<span class="input-group-text">.00</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping"
x-model="searchOutline.filters.keepUnknownShipping"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown
Shipping</label>
</div>
</div>
</section>
<section class="col-md px-3">
<h3>Metrics</h3>
<div class="mb-3">
<label for="min-purchases" class="form-label">Minimum Purchases</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-purchases"
x-model="searchOutline.filters.minPurchases" x-on:change="searchOutlineChanged"
x-on:input="validateNumericalInputs">
<span class="input-group-text">Purchases</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-purchases"
x-model="searchOutline.filters.keepUnknownPurchaseCount"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-purchases">Keep Unknown
Purchases</label>
</div>
</div>
<div class="mb-3">
<label for="min-reviews" class="form-label">Minimum Reviews</label>
<div class="input-group">
<input type="number" class="form-control" min="0" id="min-reviews"
x-model="searchOutline.filters.minReviews" x-on:change="searchOutlineChanged"
x-on:input="validateNumericalInputs">
<span class="input-group-text">Reviews</span>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-reviews"
x-model="searchOutline.filters.keepUnknownReviewCount"
x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unknown-reviews">Keep Unknown Number
of
Reviews</label>
</div>
</div>
<div class="mb-1">
<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"
x-model="searchOutline.filters.minRating" x-on:change="searchOutlineChanged">
<div id="min-rating-display" class="form-text"
x-text="searchOutline.filters.minRating + '%'">
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unrated"
x-model="searchOutline.filters.keepUnrated" x-on:change="searchOutlineChanged">
<label class="form-check-label" for="keep-unrated">Keep Unrated Items</label>
</div>
</div>
</section>
<section class="col-md px-3">
<h3>Shops Enabled</h3>
<template x-for="shopName in Object.keys(searchOutline.shopToggles)">
<div class="form-check">
<input class="form-check-input" type="checkbox" value=""
x-bind:id="`${encodeURIComponent(shopName)}-enabled`"
x-model="searchOutline.shopToggles[shopName]"
x-on:change="searchOutlineChanged">
<label class="form-check-label"
x-bind:for="`${encodeURIComponent(shopName)}-enabled`" x-text="shopName">
</label>
</div>
</template>
</section>
</div> </div>
</section> <div x-show="!searchOutline.ready">
<div class="spinner-border center-overlay" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</form>
<div id="content-pages" class="multipage mt-3 invisible"> <div id="content-pages" class="multipage mt-3 invisible">
<ul class="nav nav-pills selectors"> <ul class="nav nav-pills selectors">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#quick-picks-slide"><i <button type="button" data-bs-toggle="pill" data-bs-target="#quick-picks-slide"><i
class="bi bi-stopwatch"></i></button> class="bi bi-stopwatch"></i></button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#results-slide"><i <button type="button" data-bs-toggle="pill" data-bs-target="#results-slide"><i
class="bi bi-view-list"></i></button> class="bi bi-view-list"></i></button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button type="button" data-bs-toggle="pill" data-bs-target="#info-slide"><i <button type="button" data-bs-toggle="pill" data-bs-target="#info-slide"><i
class="bi bi-info-lg"></i></button> class="bi bi-info-lg"></i></button>
</li> </li>
</ul> </ul>
<div class="multipage-slides tab-content"> <div class="multipage-slides tab-content">
<div class="multipage-slide tab-pane fade" id="quick-picks-slide"> <div class="multipage-slide tab-pane fade" id="quick-picks-slide">
<div class="multipage-title"> <div class="multipage-title">
<h1 class="display-2"><i class="bi bi-stopwatch"></i> Quick Picks</h1> <h1 class="display-2"><i class="bi bi-stopwatch"></i> Quick Picks</h1>
@if (Model.SearchResults != null) <template x-if="hasResults()">
{ <p>@ContentManager.Json.quickPicks.searched</p>
<p>@ContentManager.Json.quickPicks.searched</p> </template>
} <template x-if="!hasResults()">
else <p>@ContentManager.Json.quickPicks.prompt</p>
{ </template>
<p>@ContentManager.Json.quickPicks.prompt</p> <hr class="less-concise">
} </div>
<hr class="less-concise"> <div class="multipage-content">
</div> <template x-if="hasResults()">
<div class="multipage-content"> <template x-if="results.bestPrice">
@if (Model.SearchResults != null) <p>Here's the listing with the lowest price.</p>
{ <div>
@if (Model.BestRatingPriceRatio != null) @* TODO: Implement best price display here *@
{ </div>
<p>We found this product to have the best rating to price ratio.</p> </template>
}
@if (Model.TopRated != null) @* TODO: Add display for top results. *@
{ </template>
<p>This listing was the one that had the highest rating.</p> <template x-if="!hasResults()">
} <div
class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
@if (Model.MostPurchases != null) <h2>@ContentManager.Json.notSearched</h2>
{
<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>Here's the listing with the lowest price.</p>
<div>
@Model.BestPrice.Name
</div> </div>
} </template>
</div>
@* TODO: Add display for top results. *@ </div>
} <div class="multipage-slide tab-pane fade" id="results-slide" x-data>
else <div class="multipage-title">
{ <h2><i class="bi bi-view-list"></i> Results</h2>
<div class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column"> <template x-if="hasResults()">
<h2>@ContentManager.Json.notSearched</h2> <p>@ContentManager.Json.results.searched</p>
</template>
<template x-if="!hasResults()">
<p>@ContentManager.Json.results.prompt</p>
</template>
<hr class="less-concise">
</div>
<div class="multipage-content">
<template x-if="hasResults()">
@* TODO: Display results with UI for sorting and changing views. *@
</template>
<template x-if="!hasResults()">
<div
class="text-center less-concise text-muted flex-grow-1 justify-content-center d-flex flex-column">
<h2>@ContentManager.Json.notSearched</h2>
</div>
</template>
</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 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> </div>

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Castle.Core.Internal; using Castle.Core.Internal;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Props.Data; using Props.Data;
@ -21,36 +22,5 @@ namespace Props.Pages
{ {
[BindProperty(Name = "q", SupportsGet = true)] [BindProperty(Name = "q", SupportsGet = true)]
public string SearchQuery { get; set; } 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; }
public ISearchManager SearchManager { get; private set; }
private UserManager<ApplicationUser> userManager;
private IMetricsManager analytics;
public SearchOutline ActiveSearchOutline { get; private set; }
public SearchModel(ISearchManager searchManager, UserManager<ApplicationUser> userManager, IMetricsManager analyticsManager)
{
this.SearchManager = searchManager;
this.userManager = userManager;
this.analytics = analyticsManager;
}
public async Task OnGetAsync()
{
ActiveSearchOutline = User.Identity.IsAuthenticated ? (await userManager.GetUserAsync(User)).searchOutlinePreferences.ActiveSearchOutline : new SearchOutline();
if (string.IsNullOrWhiteSpace(SearchQuery)) return;
Console.WriteLine(SearchQuery);
this.SearchResults = await 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

@ -13,6 +13,6 @@ namespace Props.Services.Modules
public void RegisterSearchQuery(string query); public void RegisterSearchQuery(string query);
public void RegisterListing(ProductListing productListing, string shopName); public void RegisterProductListing(ProductListing productListing, string shopName);
} }
} }

View File

@ -1,21 +1,29 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Props.Data; using Props.Data;
using Props.Models.Search; using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework; using Props.Shop.Framework;
namespace Props.Services.Modules namespace Props.Services.Modules
{ {
public class LiveMetricsManager : IMetricsManager public class LiveMetricsManager : IMetricsManager
{ {
private MetricsOptions metricsOptions;
private ILogger<LiveMetricsManager> logger; private ILogger<LiveMetricsManager> logger;
ApplicationDbContext dbContext; private ApplicationDbContext dbContext;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger) private IQueryable<ProductListingInfo> leastPopularProductListings;
private IQueryable<QueryWordInfo> leastPopularQueryWords;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger, IConfiguration configuration)
{ {
this.metricsOptions = configuration.GetSection(MetricsOptions.Metrics).Get<MetricsOptions>();
this.logger = logger; this.logger = logger;
this.dbContext = dbContext; this.dbContext = dbContext;
leastPopularProductListings = from listing in dbContext.ProductListingInfos orderby listing.Hits ascending select listing;
leastPopularQueryWords = from word in dbContext.QueryWords orderby word.Hits ascending select word;
} }
public IEnumerable<ProductListingInfo> RetrieveTopListings(int max) public IEnumerable<ProductListingInfo> RetrieveTopListings(int max)
{ {
@ -27,8 +35,8 @@ namespace Props.Services.Modules
public IEnumerable<string> RetrieveCommonKeywords(int max) public IEnumerable<string> RetrieveCommonKeywords(int max)
{ {
if (dbContext.Keywords == null) return null; if (dbContext.QueryWords == null) return null;
return (from k in dbContext.Keywords return (from k in dbContext.QueryWords
orderby k.Hits descending orderby k.Hits descending
select k.Word).Take(max); select k.Word).Take(max);
} }
@ -40,11 +48,11 @@ namespace Props.Services.Modules
QueryWordInfo[] wordInfos = new QueryWordInfo[tokens.Length]; QueryWordInfo[] wordInfos = new QueryWordInfo[tokens.Length];
for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++) for (int wordIndex = 0; wordIndex < tokens.Length; wordIndex++)
{ {
QueryWordInfo queryWordInfo = dbContext.Keywords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo(); QueryWordInfo queryWordInfo = dbContext.QueryWords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo();
if (queryWordInfo.Hits == 0) if (queryWordInfo.Hits == 0)
{ {
queryWordInfo.Word = tokens[wordIndex]; queryWordInfo.Word = tokens[wordIndex];
dbContext.Keywords.Add(queryWordInfo); dbContext.QueryWords.Add(queryWordInfo);
} }
queryWordInfo.Hits += 1; queryWordInfo.Hits += 1;
wordInfos[wordIndex] = queryWordInfo; wordInfos[wordIndex] = queryWordInfo;
@ -60,11 +68,12 @@ namespace Props.Services.Modules
wordInfos[wordIndex].Following.Add(wordInfos[afterIndex]); wordInfos[wordIndex].Following.Add(wordInfos[afterIndex]);
} }
} }
dbContext.SaveChanges();
CullQueryWords();
dbContext.SaveChanges();
} }
public void RegisterListing(ProductListing productListing, string shopName) public void RegisterProductListing(ProductListing productListing, string shopName)
{ {
ProductListingInfo productListingInfo = ProductListingInfo productListingInfo =
(from info in dbContext.ProductListingInfos (from info in dbContext.ProductListingInfos
@ -78,7 +87,27 @@ namespace Props.Services.Modules
productListingInfo.ProductListing = productListing; productListingInfo.ProductListing = productListing;
productListingInfo.ProductListingIdentifier = productListing.Identifier; productListingInfo.ProductListingIdentifier = productListing.Identifier;
productListingInfo.Hits += 1; productListingInfo.Hits += 1;
CullProductListings();
dbContext.SaveChanges(); dbContext.SaveChanges();
} }
private void CullProductListings()
{
int surplus = dbContext.ProductListingInfos.Count() - metricsOptions.MaxProductListings;
if (surplus > 0)
{
dbContext.RemoveRange(leastPopularProductListings.Take(surplus));
}
}
private void CullQueryWords()
{
int surplus = dbContext.QueryWords.Count() - metricsOptions.MaxQueryWords;
if (surplus > 0)
{
dbContext.RemoveRange(leastPopularQueryWords.Take(surplus));
}
}
} }
} }

View File

@ -34,7 +34,7 @@ namespace Props.Services.Modules
foreach (string shopName in await ShopManager.GetAllShopNames()) foreach (string shopName in await ShopManager.GetAllShopNames())
{ {
if (searchOutline.Enabled[shopName]) if (searchOutline.DisabledShops[shopName])
{ {
logger.LogDebug("Checking \"{0}\".", shopName); logger.LogDebug("Checking \"{0}\".", shopName);
int amount = 0; int amount = 0;
@ -43,7 +43,7 @@ namespace Props.Services.Modules
if (searchOutline.Filters.Validate(product)) if (searchOutline.Filters.Validate(product))
{ {
amount += 1; amount += 1;
metricsManager.RegisterListing(product, shopName); metricsManager.RegisterProductListing(product, shopName);
results.Add(product); results.Add(product);
} }
if (amount >= searchOptions.MaxResults) break; if (amount >= searchOptions.MaxResults) break;

View File

@ -1,13 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

View File

@ -21,5 +21,9 @@
"Textual": { "Textual": {
"Dir": "./textual" "Dir": "./textual"
}, },
"Metrics": {
"MaxQueryWords": 4096,
"MaxProductListings": 1024
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@ -1,3 +1,8 @@
import { apiHttp } from "../services/http";
import Alpine from "alpinejs";
import clone from "just-clone";
const uploadDelay = 500;
const startingSlide = "#quick-picks-slide"; const startingSlide = "#quick-picks-slide";
function initInteractiveElements() { function initInteractiveElements() {
@ -11,14 +16,251 @@ function initInteractiveElements() {
}); });
} }
function initConfigVisuals() { function initConfigData() {
const minRatingDisplay = document.querySelector("#configuration #min-rating-display"); document.addEventListener("alpine:init", () => {
const minRatingSlider = document.querySelector("#configuration #min-rating"); Alpine.data("search", () => ({
const updateDisplay = function () { loggedIn: false,
minRatingDisplay.innerHTML = `Minimum rating: ${minRatingSlider.value}%`; query: "",
}; searchOutline: {
minRatingSlider.addEventListener("input", updateDisplay); ready: false,
updateDisplay(); filters: {
"currency": 0,
"minRating": 80,
"keepUnrated": true,
"enableUpperPrice": false,
"upperPrice": 0,
"lowerPrice": 0,
"minPurchases": 0,
"keepUnknownPurchaseCount": true,
"minReviews": 0,
"keepUnknownReviewCount": true,
"enableMaxShipping": false,
"maxShippingFee": 0,
"keepUnknownShipping": true
},
shopToggles: {},
},
deletingSearchOutline: false,
creatingSearchOutline: false,
updatingLastUsed: false,
changingName: false,
updatingFilters: false,
updatingDisabledShops: false,
searchOutlines: [],
selectedSearchOutline: 0,
serverSearchOutlineName: null,
resultsQuery: null,
results: {
bestPrice: null,
},
searchOutlineChangeTimeout: null,
hasResults() {
return this.resultsQuery !== null;
},
submitSearch() {
// TODO: implement search Web API call.
this.resultsQuery = this.query;
console.log("Search requested.");
},
SearchOutlineNameChange() {
if (this.validateSearchOutlineName()) {
this.changeSearchOutlineName(this.serverSearchOutlineName, this.searchOutlines[this.selectedSearchOutline]);
}
},
validateNumericalInputs() {
if (!this.searchOutline.filters.lowerPrice) this.searchOutline.filters.lowerPrice = 0;
if (!this.searchOutline.filters.upperPrice) this.searchOutline.filters.upperPrice = 0;
if (!this.searchOutline.filters.maxShippingFee) this.searchOutline.filters.maxShippingFee = 0;
if (!this.searchOutline.filters.minPurchases) this.searchOutline.filters.minPurchases = 0;
if (!this.searchOutline.filters.minRating) this.searchOutline.filters.minRating = 0;
if (!this.searchOutline.filters.minReviews) this.searchOutline.filters.minReviews = 0;
},
validateSearchOutlineName() {
let clonedSearchOutlines = this.searchOutlines.slice();
clonedSearchOutlines.splice(this.selectedSearchOutline, 1);
if (this.searchOutlines[this.selectedSearchOutline].length < 1 || clonedSearchOutlines.includes(this.searchOutlines[this.selectedSearchOutline])) {
this.searchOutlines[this.selectedSearchOutline] = this.serverSearchOutlineName;
return false;
}
return true;
},
searchOutlineChanged() {
if (!this.loggedIn) return;
if (this.searchOutlineChangeTimeout != null) {
clearTimeout(this.searchOutlineChangeTimeout);
}
this.searchOutlineChangeTimeout = setTimeout(() => {
this.uploadAll();
}, uploadDelay);
},
uploadAll() {
let name = this.searchOutlines[this.selectedSearchOutline];
this.uploadFilters(name, clone(this.searchOutline.filters));
this.uploadDisabledShops(name, clone(this.searchOutline.shopToggles));
},
async uploadFilters(name, filters) {
if (!this.loggedIn) return;
this.updatingFilters = true;
let uploadFilterResponse = await apiHttp.put(`SearchOutline/${name}/Filters`, filters);
this.updatingFilters = false;
if (uploadFilterResponse.status != 204) {
throw `Error while attempting to upload filters. Response code ${uploadFilterResponse.status} (expected 204).`;
}
},
async uploadDisabledShops(name, disabledShops) {
if (!this.loggedIn) return;
this.updatingDisabledShops = true;
let disabledShopSet = [];
Object.keys(disabledShops).forEach(key => {
if (!this.searchOutline.shopToggles[key]) {
disabledShopSet.push(key);
}
});
let uploadDisabledShopsResponse = await apiHttp.put(`SearchOutline/${name}/DisabledShops`, disabledShopSet);
this.updatingDisabledShops = false;
if (uploadDisabledShopsResponse.status != 204) {
throw `Error while attempting to upload disabled shops. Response code ${uploadDisabledShopsResponse.status} (expected 204).`;
}
},
async createSearchOutline(name) {
if (!this.loggedIn) return;
this.creatingSearchOutline = true;
let createRequest = await apiHttp.post("SearchOutline/" + name);
this.creatingSearchOutline = false;
if (createRequest.status != 204) {
throw `Could not create profile. Response code ${createRequest.status} (expected 204).`;
}
this.searchOutlines.push(name);
},
async updateLastUsed(name) {
if (!this.loggedIn) return;
this.updatingLastUsed = true;
let lastUsedRequest = await apiHttp.put("SearchOutline/" + name + "/LastUsed");
this.updatingLastUsed = false;
if (lastUsedRequest.status != 204) {
throw `Could not update last used search outline. Received status code ${lastUsedRequest.status} (expected 204).`;
}
},
async changeSearchOutlineName(old, current) {
if (!this.loggedIn) return;
this.changingName = true;
let nameChangeRequest = await apiHttp.put(`SearchOutline/${old}/Name/${current}`);
this.changingName = false;
if (nameChangeRequest.status != 204) {
throw `Could not update name on server side. Received ${nameChangeRequest.status} (expected 204).`;
}
this.serverSearchOutlineName = current;
},
async loadSearchOutline(name) {
this.searchOutline.ready = false;
if (!this.loggedIn) {
let defaultNameRequest = await apiHttp.get("SearchOutline/DefaultName");
if (defaultNameRequest.status != 200) {
console.error(`Could not load default search outline name. Got response code ${defaultNameRequest.status} (Expected 200).`);
return;
}
this.searchOutlines.push(defaultNameRequest.data);
let disabledShopsResponse = await apiHttp.get("SearchOutline/DefaultDisabledShops");
let availableShops = (await apiHttp.get("Search/AvailableShops")).data;
if (disabledShopsResponse.status == 200) {
availableShops.forEach(shopName => {
this.searchOutline.shopToggles[shopName] = !disabledShopsResponse.data.includes(shopName);
});
} else {
console.error(`Could not fetch default disabled shops for "${name}". Status code: ${disabledShopsResponse.status} (Expected 200)`);
return;
}
} else {
if (this.searchOutlineChangeTimeout != null) {
clearTimeout(this.searchOutlineChangeTimeout);
this.uploadAll();
}
let filterResponse = await apiHttp.get("SearchOutline/" + name + "/Filters");
if (filterResponse.status == 200) {
this.searchOutline.filters = filterResponse.data;
} else {
console.error(`Could not fetch filter for "${name}". Status code: ${filterResponse.status} (Expected 200)`);
return;
}
let disabledShopsResponse = await apiHttp.get("SearchOutline/" + name + "/DisabledShops");
let availableShops = (await apiHttp.get("Search/AvailableShops")).data;
if (disabledShopsResponse.status == 200) {
availableShops.forEach(shopName => {
this.searchOutline.shopToggles[shopName] = !disabledShopsResponse.data.includes(shopName);
});
} else {
console.error(`Could not fetch disabled shops for "${name}". Status code: ${disabledShopsResponse.status} (Expected 200)`);
return;
}
await this.updateLastUsed(name);
}
this.serverSearchOutlineName = name;
this.searchOutline.ready = true;
},
createSearchOutlineWithGeneratedName() {
apiHttp.get("SearchOutline/DefaultName/").then((response) => {
if (response.status != 200) {
throw `Could not get a default name. Response code ${response.status} (expected 200).`;
}
this.createSearchOutline(response.data);
});
},
deleteSearchOutline(name) {
this.deletingSearchOutline = true;
let beforeDelete = this.searchOutlines[this.selectedSearchOutline];
if (this.selectedSearchOutline == this.searchOutlines.length - 1 && this.searchOutlines.indexOf(name) <= this.selectedSearchOutline) {
this.selectedSearchOutline -= 1;
}
apiHttp.delete(`SearchOutline/${name}`).then((results) => {
this.deletingSearchOutline = false;
if (results.status != 204) {
throw `Unable to delete ${name}. Received status ${results.status} (expected 204)`;
}
this.searchOutlines.splice(this.searchOutlines.indexOf(name), 1);
if (beforeDelete !== this.searchOutlines[this.selectedSearchOutline]) {
this.loadSearchOutline(this.searchOutlines[this.selectedSearchOutline]).then(() => {
this.deletingSearchOutline = false;
});
} else {
this.deletingSearchOutline = false;
}
});
},
async init() {
// TODO: Test logged in outline sequence and logged out outline sequence.
this.loggedIn = (await apiHttp.get("User/LoggedIn")).data;
if (this.loggedIn) {
this.searchOutlines = (await apiHttp.get("SearchOutline/Names")).data;
if (this.searchOutlines.length == 0) {
let name = (await apiHttp.get("SearchOutline/DefaultName")).data;
try {
await this.createSearchOutline(name);
await this.updateLastUsed(name);
} catch (error) {
console.error(error);
return;
}
} else {
let lastUsedRequest = await apiHttp.get("SearchOutline/LastUsed");
if (lastUsedRequest.status == 200) {
this.selectedSearchOutline = this.searchOutlines.indexOf(lastUsedRequest.data);
} else {
console.warn(`Could not load name of last used search outline. Got response code ${lastUsedRequest.status} (Expected 200). Using "${this.searchOutlines[0]}".`);
let putlastUsedRequest = await apiHttp.put("SearchOutline/" + this.searchOutlines[this.selectedSearchOutline] + "/LastUsed");
if (putlastUsedRequest.status != 204) {
console.error(`Could not update last used search outline. Received status code ${putlastUsedRequest.status} (Expected 204).`);
return;
}
}
}
}
this.loadSearchOutline(this.searchOutlines[this.selectedSearchOutline]);
}
}));
});
Alpine.start();
} }
function initSlides() { function initSlides() {
@ -46,8 +288,8 @@ function initSlides() {
async function main() { async function main() {
initInteractiveElements(); initInteractiveElements();
initConfigVisuals();
initSlides(); initSlides();
initConfigData();
} }
main(); main();

View File

@ -37,17 +37,6 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.tear {
border-top: 1px;
border-top-style: solid;
border-bottom: 1px;
border-bottom-style: solid;
@include themer.themed {
$tear: themer.color-of("background");
border-color: adjust-color($color: $tear, $lightness: -12%, $alpha: 1.0);
background-color: adjust-color($color: $tear, $lightness: -5%, $alpha: 1.0);
}
}
} }
footer { footer {
@ -82,16 +71,24 @@ footer {
} }
} }
.center-overlay {
position: absolute;
left: 50%;
right: 50%;
bottom: 50%;
top: 50%;
}
.concise { .concise {
@extend .container; @extend .container;
max-width: 630px; max-width: 630px;
width: inherit; width: 100%;
} }
.less-concise { .less-concise {
@extend .container; @extend .container;
max-width: 720px; max-width: 720px;
width: inherit; width: 100%;
} }
hr { hr {
@ -111,6 +108,115 @@ hr {
} }
} }
.tear {
@include themer.themed {
$tear: themer.color-of("background");
background-color: adjust-color($color: $tear, $lightness: -8%, $alpha: 1.0);
box-shadow: 0px 0px 5px 0px adjust-color($color: $tear, $lightness: -25%, $alpha: 1.0) inset;
}
}
input[type="text"].title-input {
@include themer.themed {
color: themer.color-of("text");
}
font-size: 2.5em;
margin-bottom: 1.5rem;
border-style: none;
border-bottom: 1px solid transparent;
background-color: transparent;
text-align: center;
transition: border-bottom-color 0.5s;
&:hover:not(:disabled) {
@include themer.themed {
border-bottom-color: adjust-color($color: themer.color-of("text"), $lightness: 50%);
}
}
&:focus:not(:disabled) {
outline: none;
@include themer.themed {
border-bottom-color: adjust-color($color: themer.color-of("text"));
}
}
&:disabled {
border-bottom-color: transparent;
@include themer.themed {
color: themer.color-of("text");
}
}
}
.clean-radio {
input[type="radio"] {
display: none;
& + label {
transition: border-color 0.5s;
padding: 0.5rem;
border-left: 3px solid;
@include themer.themed {
border-color: themer.color-of("text");
}
cursor: pointer;
}
&:hover + label {
@include themer.themed {
border-color: adjust-color($color: themer.color-of("special"), $lightness: 30%);
}
}
&:checked + label {
@include themer.themed {
border-color: adjust-color($color: themer.color-of("special"), $lightness: 0%);
}
}
}
button.btn {
transition: color 0.5s;
color: transparent;
}
&:hover {
button.btn {
@include themer.themed {
color: themer.color-of("text");
}
}
}
}
.border-right-themed {
border-right: 1px solid;
@include themer.themed {
border-right-color: themer.color-of("text");
}
}
.border-left-themed {
border-left: 1px solid;
@include themer.themed {
border-left-color: themer.color-of("text");
}
}
.border-top-themed {
border-top: 1px solid;
@include themer.themed {
border-top-color: themer.color-of("text");
}
}
.border-bottom-themed {
border-bottom: 1px solid;
@include themer.themed {
border-bottom-color: themer.color-of("text");
}
}
html { html {
min-height: 100%; min-height: 100%;
display: flex; display: flex;

View File

@ -2773,6 +2773,11 @@
"minimist": "^1.2.5" "minimist": "^1.2.5"
} }
}, },
"just-clone": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/just-clone/-/just-clone-3.2.1.tgz",
"integrity": "sha512-PFotEVrrzAnwuWTUOFquDShWrHnUnhxNrVs1VFqkNfnoH3Sn5XUlDOePYn2Vv5cN8xV2y69jf8qEoQHm7eoLnw=="
},
"kind-of": { "kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -3012,6 +3017,11 @@
"object-keys": "^1.1.1" "object-keys": "^1.1.1"
} }
}, },
"on-change": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/on-change/-/on-change-3.0.2.tgz",
"integrity": "sha512-rdt5YfIfo86aFNwvQqzzHMpaPPyVQ/XjcGK01d46chZh47G8Xzvoao79SgFb03GZfxRGREzNQVJuo31drqyIlA=="
},
"once": { "once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View File

@ -34,6 +34,8 @@
"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",
"just-clone": "^3.2.1",
"on-change": "^3.0.2",
"simplebar": "^5.3.5" "simplebar": "^5.3.5"
} }
} }

View File

@ -5,7 +5,7 @@
"That's it! Hit search and we'll look far and wide for what you need!" "That's it! Hit search and we'll look far and wide for what you need!"
], ],
"quickPicks": { "quickPicks": {
"searched": "To save you some time, these are some of the better options we found!", "searched": "The cream of the crop!",
"prompt": "This is where we'll show you top listings so you can get back to working on your project!" "prompt": "This is where we'll show you top listings so you can get back to working on your project!"
}, },
"results": { "results": {