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)
{
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));
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));
}

View File

@ -5,12 +5,12 @@ namespace Props.Shop.Framework
public class Filters
{
public Currency Currency { get; set; } = Currency.CAD;
private float minRatingNormalized;
private float minRatingNormalized = 0.8f;
public int MinRating
{
get
{
return (int)(minRatingNormalized * 100);
return (int)(minRatingNormalized * 100f);
}
set
{
@ -38,7 +38,7 @@ namespace Props.Shop.Framework
public bool KeepUnknownPurchaseCount { get; set; } = true;
public int MinReviews { get; set; }
public bool KeepUnknownReviewCount { get; set; } = true;
public bool EnableMaxShippingFee { get; set; }
public bool EnableMaxShipping { get; set; }
private int maxShippingFee;
public int MaxShippingFee
@ -49,7 +49,7 @@ namespace Props.Shop.Framework
}
set
{
if (EnableMaxShippingFee) maxShippingFee = value;
if (EnableMaxShipping) maxShippingFee = value;
}
}
public bool KeepUnknownShipping { get; set; } = true;
@ -72,7 +72,7 @@ namespace Props.Shop.Framework
KeepUnknownPurchaseCount == other.KeepUnknownPurchaseCount &&
MinReviews == other.MinReviews &&
KeepUnknownReviewCount == other.KeepUnknownReviewCount &&
EnableMaxShippingFee == other.EnableMaxShippingFee &&
EnableMaxShipping == other.EnableMaxShipping &&
MaxShippingFee == other.MaxShippingFee &&
KeepUnknownShipping == other.KeepUnknownShipping;
@ -96,7 +96,7 @@ namespace Props.Shop.Framework
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;
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;

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 Props.Models.Search;
using Props.Services.Modules;
using Props.Shop.Framework;
namespace Props.Controllers
{
public class SearchController : ApiControllerBase
{
private SearchOutline defaultOutline = new SearchOutline();
IShopManager shopManager;
ISearchManager searchManager;
public SearchController(IShopManager shopManager)
public SearchController(ISearchManager searchManager)
{
this.shopManager = shopManager;
this.searchManager = searchManager;
}
[HttpGet]
[Route("Available")]
public IActionResult GetAvailableShops()
[Route("AvailableShops")]
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 Props.Data;
using Props.Models.Search;
using Props.Services.Modules;
using Props.Models.User;
using Props.Shop.Framework;
namespace Props.Controllers
{
public class SearchOutlineController : ApiControllerBase
{
private SearchOutline defaultOutline = new SearchOutline();
public SearchOutlineController()
private ApplicationDbContext dbContext;
private UserManager<ApplicationUser> userManager;
public SearchOutlineController(UserManager<ApplicationUser> userManager, ApplicationDbContext dbContext)
{
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]
[Route("Filters")]
public IActionResult GetFilters()
[Authorize]
[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]
[Route("DisabledShops")]
public IActionResult GetDisabledShops()
[Authorize]
[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]
[Route("SearchOutlineName")]
public IActionResult GetSearchOutlineName()
[Authorize]
[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 DbSet<QueryWordInfo> Keywords { get; set; }
public DbSet<QueryWordInfo> QueryWords { get; set; }
public DbSet<ProductListingInfo> ProductListingInfos { get; set; }
@ -43,14 +43,14 @@ namespace Props.Data
);
modelBuilder.Entity<SearchOutline>()
.Property(e => e.Enabled)
.Property(e => e.DisabledShops)
.HasConversion(
v => JsonSerializer.Serialize(v, null),
v => JsonSerializer.Deserialize<SearchOutline.ShopsDisabled>(v, null),
new ValueComparer<SearchOutline.ShopsDisabled>(
v => JsonSerializer.Deserialize<SearchOutline.ShopSelector>(v, null),
new ValueComparer<SearchOutline.ShopSelector>(
(a, b) => a.Equals(b),
c => c.GetHashCode(),
c => c.Copy()
c => new SearchOutline.ShopSelector(c)
)
);

View File

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

View File

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

View File

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

View File

@ -11,53 +11,45 @@ namespace Props.Models.Search
{
public int Id { get; set; }
[Required]
public string ApplicationUserId { get; set; }
public int SearchOutlinePreferencesId { get; set; }
[Required]
public virtual ApplicationUser ApplicationUser { get; set; }
public virtual SearchOutlinePreferences SearchOutlinePreferences { get; set; }
[Required]
public string Name { get; set; } = "Default";
public Filters Filters { get; set; } = new Filters();
public string Name { get; set; }
public Filters Filters { get; set; }
[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]
{
get
{
return !this.Contains(name);
return this.Contains(name);
}
set
{
if (value == false && TotalShops - Count <= 1) return;
if (value)
{
this.Remove(name);
this.Add(name);
}
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;
return
Id == other.Id &&
Name.Equals(other.Name) &&
Filters.Equals(other.Filters) &&
Enabled.Equals(other.Enabled);
DisabledShops.Equals(other.DisabledShops);
}
public override int GetHashCode()
{
return HashCode.Combine(Id, Name);
return HashCode.Combine(Id, Name, Filters, DisabledShops);
}
public SearchOutline()
{
this.Name = "Default";
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.Filters = filters;
this.Enabled = disabled;
this.DisabledShops = disabled;
}
}
}

View File

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

View File

@ -16,22 +16,19 @@ namespace Props.Models.User
public virtual ApplicationUser ApplicationUser { get; set; }
[Required]
public virtual ISet<SearchOutline> SearchOutlines { get; set; }
public virtual IList<SearchOutline> SearchOutlines { get; set; }
[Required]
public virtual SearchOutline ActiveSearchOutline { get; set; }
public string NameOfLastUsed { get; set; }
public SearchOutlinePreferences()
{
SearchOutlines = new HashSet<SearchOutline>();
ActiveSearchOutline = new SearchOutline();
SearchOutlines.Add(ActiveSearchOutline);
SearchOutlines = new List<SearchOutline>();
}
public SearchOutlinePreferences(ISet<SearchOutline> searchOutlines, SearchOutline activeSearchOutline)
public SearchOutlinePreferences(List<SearchOutline> searchOutlines, string nameOfLastUsed)
{
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";
}
<form method="GET">
<div class="mt-4 mb-3 less-concise mx-auto">
<div class="flex-grow-1 d-flex flex-column" x-data="search">
<div class="mt-4 mb-3 less-concise">
<div class="input-group">
<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"
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 class="collapse tear" id="configuration">
<div class="container my-3">
<div class="d-flex">
<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>
</div>
<div class="row justify-content-md-center">
<section class="col-lg px-4">
<h3>Price</h3>
<div class="mb-3">
<label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
checked="@Model.ActiveSearchOutline.Filters.EnableUpperPrice"
name="ActiveSearchOutline.Filters.EnableUpperPrice">
<div class="d-flex">
<h1 class="my-3 display-2 mx-auto">
<i class="bi bi-sliders"></i>
Configuration
</h1>
<button class="btn align-self-start m-3" type="button" id="configuration-close" data-bs-toggle="collapse"
data-bs-target="#configuration"><i class="bi bi-x-lg"></i></button>
</div>
<div class="container">
<div class="row my-3">
<div class="col-lg-3 px-2">
<div class="row">
<div class="col">
<h3>Search Outlines</h3>
</div>
<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>
<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 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"
value="@Model.ActiveSearchOutline.Filters.LowerPrice"
name="ActiveSearchOutline.Filters.LowerPrice">
<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"
checked="@Model.ActiveSearchOutline.Filters.EnableMaxShippingFee"
name="ActiveSearchOutline.Filters.EnableMaxShippingFee">
<div class="row px-3">
<div style="max-height: 28em;" data-simplebar>
<template x-for="(current, i) in searchOutlines">
<div class="clean-radio d-flex">
<input type="radio" x-bind:id="`${i}-selector`" name="search-outline"
x-bind:value="i" x-model="selectedSearchOutline"
x-on:click="loadSearchOutline(current)" x-bind:disabled="!searchOutline.ready">
<label class="flex-grow-1" x-bind:for="`${i}-selector`">
<span class="me-auto" x-text="current"></span>
</label>
<button class="btn m-1" x-show="loggedIn" x-on:click="deleteSearchOutline(current)"
x-bind:disabled="deletingSearchOutline || (searchOutlines.length < 2)">
<i class="bi bi-trash"></i>
</button>
</div>
</template>
<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>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-shipping"
value="@Model.ActiveSearchOutline.Filters.MaxShippingFee"
name="ActiveSearchOutline.Filters.MaxShippingFee">
<span class="input-group-text">.00</span>
<template x-if="(searchOutlines.length < 2) && (loggedIn)">
<div class="text-muted text-center my-3 p-3">
<p>Add more search outlines by clicking the <i class="bi bi-plus-lg"></i> above!</p>
</div>
</template>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="keep-unknown-shipping"
checked="@Model.ActiveSearchOutline.Filters.KeepUnknownShipping"
name="ActiveSearchOutline.Filters.KeepUnknownShipping">
<label class="form-check-label" for="keep-unknown-shipping">Keep Unknown Shipping</label>
</div>
</div>
<div class="col-lg px-4">
<div class="row">
<input class="title-input less-concise mx-4"
x-bind:class="searchOutline.ready ? '' : 'invisible'" type="text"
x-model="searchOutlines[selectedSearchOutline]" x-on:change="SearchOutlineNameChange"
x-bind:disabled="(!loggedIn) || (changingName)">
</div>
</section>
<section class="col-lg px-4">
<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"
value="@Model.ActiveSearchOutline.Filters.MinPurchases"
name="ActiveSearchOutline.Filters.MinPurchases">
<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"
checked="@Model.ActiveSearchOutline.Filters.KeepUnknownPurchaseCount"
name="ActiveSearchOutline.Filters.KeepUnknownPurchaseCount">
<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"
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 class="row justify-content-md-center" x-bind:class="searchOutline.ready ? '' : 'invisible'">
<section class="col-md px-3">
<h3>Price</h3>
<div class="mb-3">
<label for="max-price" class="form-label">Maximum Price</label>
<div class="input-group">
<div class="input-group-text">
<input class="form-check-input mt-0" type="checkbox" id="max-price-enabled"
x-model="searchOutline.filters.enableUpperPrice"
x-on:change="searchOutlineChanged">
</div>
<span class="input-group-text">$</span>
<input type="number" class="form-control" min="0" id="max-price"
x-model="searchOutline.filters.upperPrice"
x-bind:disabled="!searchOutline.filters.enableUpperPrice"
x-on:change="searchOutlineChanged" x-on:input="validateNumericalInputs">
<span class="input-group-text">.00</span>
</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>
</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>
</form>
<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>
}
<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>
<template x-if="hasResults()">
<p>@ContentManager.Json.quickPicks.searched</p>
</template>
<template x-if="!hasResults()">
<p>@ContentManager.Json.quickPicks.prompt</p>
</template>
<hr class="less-concise">
</div>
<div class="multipage-content">
<template x-if="hasResults()">
<template x-if="results.bestPrice">
<p>Here's the listing with the lowest price.</p>
<div>
@* TODO: Implement best price display here *@
</div>
</template>
@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>Here's the listing with the lowest price.</p>
<div>
@Model.BestPrice.Name
@* TODO: Add display for top results. *@
</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>
}
@* 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>
</template>
</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>
<template x-if="hasResults()">
<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 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>

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using Castle.Core.Internal;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Props.Data;
@ -21,36 +22,5 @@ namespace Props.Pages
{
[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; }
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 RegisterListing(ProductListing productListing, string shopName);
public void RegisterProductListing(ProductListing productListing, string shopName);
}
}

View File

@ -1,21 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Props.Data;
using Props.Models.Search;
using Props.Options;
using Props.Shop.Framework;
namespace Props.Services.Modules
{
public class LiveMetricsManager : IMetricsManager
{
private MetricsOptions metricsOptions;
private ILogger<LiveMetricsManager> logger;
ApplicationDbContext dbContext;
public LiveMetricsManager(ApplicationDbContext dbContext, ILogger<LiveMetricsManager> logger)
private ApplicationDbContext dbContext;
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.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)
{
@ -27,8 +35,8 @@ namespace Props.Services.Modules
public IEnumerable<string> RetrieveCommonKeywords(int max)
{
if (dbContext.Keywords == null) return null;
return (from k in dbContext.Keywords
if (dbContext.QueryWords == null) return null;
return (from k in dbContext.QueryWords
orderby k.Hits descending
select k.Word).Take(max);
}
@ -40,11 +48,11 @@ namespace Props.Services.Modules
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();
QueryWordInfo queryWordInfo = dbContext.QueryWords.Where((k) => k.Word.ToLower().Equals(tokens[wordIndex])).SingleOrDefault() ?? new QueryWordInfo();
if (queryWordInfo.Hits == 0)
{
queryWordInfo.Word = tokens[wordIndex];
dbContext.Keywords.Add(queryWordInfo);
dbContext.QueryWords.Add(queryWordInfo);
}
queryWordInfo.Hits += 1;
wordInfos[wordIndex] = queryWordInfo;
@ -60,11 +68,12 @@ namespace Props.Services.Modules
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 =
(from info in dbContext.ProductListingInfos
@ -78,7 +87,27 @@ namespace Props.Services.Modules
productListingInfo.ProductListing = productListing;
productListingInfo.ProductListingIdentifier = productListing.Identifier;
productListingInfo.Hits += 1;
CullProductListings();
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())
{
if (searchOutline.Enabled[shopName])
if (searchOutline.DisabledShops[shopName])
{
logger.LogDebug("Checking \"{0}\".", shopName);
int amount = 0;
@ -43,7 +43,7 @@ namespace Props.Services.Modules
if (searchOutline.Filters.Validate(product))
{
amount += 1;
metricsManager.RegisterListing(product, shopName);
metricsManager.RegisterProductListing(product, shopName);
results.Add(product);
}
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.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

View File

@ -21,5 +21,9 @@
"Textual": {
"Dir": "./textual"
},
"Metrics": {
"MaxQueryWords": 4096,
"MaxProductListings": 1024
},
"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";
function initInteractiveElements() {
@ -11,14 +16,251 @@ function initInteractiveElements() {
});
}
function initConfigVisuals() {
const minRatingDisplay = document.querySelector("#configuration #min-rating-display");
const minRatingSlider = document.querySelector("#configuration #min-rating");
const updateDisplay = function () {
minRatingDisplay.innerHTML = `Minimum rating: ${minRatingSlider.value}%`;
};
minRatingSlider.addEventListener("input", updateDisplay);
updateDisplay();
function initConfigData() {
document.addEventListener("alpine:init", () => {
Alpine.data("search", () => ({
loggedIn: false,
query: "",
searchOutline: {
ready: false,
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() {
@ -46,8 +288,8 @@ function initSlides() {
async function main() {
initInteractiveElements();
initConfigVisuals();
initSlides();
initConfigData();
}
main();

View File

@ -37,17 +37,6 @@ main {
display: flex;
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 {
@ -82,16 +71,24 @@ footer {
}
}
.center-overlay {
position: absolute;
left: 50%;
right: 50%;
bottom: 50%;
top: 50%;
}
.concise {
@extend .container;
max-width: 630px;
width: inherit;
width: 100%;
}
.less-concise {
@extend .container;
max-width: 720px;
width: inherit;
width: 100%;
}
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 {
min-height: 100%;
display: flex;

View File

@ -2773,6 +2773,11 @@
"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": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -3012,6 +3017,11 @@
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View File

@ -34,6 +34,8 @@
"axios": "^0.21.1",
"bootstrap": "^5.0.2",
"bootstrap-icons": "^1.5.0",
"just-clone": "^3.2.1",
"on-change": "^3.0.2",
"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!"
],
"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!"
},
"results": {