Browse Source

add autocomplete to search bar (#791)

pull/792/head
Jason Dove 4 years ago committed by GitHub
parent
commit
3ad1ba01f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Search/Queries/QuerySearchTargets.cs
  3. 54
      ErsatzTV.Application/Search/Queries/QuerySearchTargetsHandler.cs
  4. 18
      ErsatzTV.Application/Search/SearchTargetResultViewModel.cs
  5. 111
      ErsatzTV/Shared/MainLayout.razor

1
CHANGELOG.md

@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Add `metadata_kind` field to search index to allow searching for items with a particular metdata source
- Valid metadata kinds are `fallback`, `sidecar` (NFO), `external` (from a media server) and `embedded` (songs)
- Add autocomplete functionality to search bar to quickly navigate to channels, ffmpeg profiles, collections and schedules by name
### Changed
- Replace invalid (control) characters in NFO metadata with replacement character `<EFBFBD>` before parsing

3
ErsatzTV.Application/Search/Queries/QuerySearchTargets.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Search;
public record QuerySearchTargets : IRequest<List<SearchTargetViewModel>>;

54
ErsatzTV.Application/Search/Queries/QuerySearchTargetsHandler.cs

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class QuerySearchTargetsHandler : IRequestHandler<QuerySearchTargets, List<SearchTargetViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public QuerySearchTargetsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SearchTargetViewModel>> Handle(
QuerySearchTargets request,
CancellationToken cancellationToken)
{
var result = new List<SearchTargetViewModel>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
result.AddRange(
dbContext.Channels
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Channel)));
result.AddRange(
dbContext.FFmpegProfiles
.Map(f => new SearchTargetViewModel(f.Id, f.Name, SearchTargetKind.FFmpegProfile)));
result.AddRange(
dbContext.ChannelWatermarks
.Map(w => new SearchTargetViewModel(w.Id, w.Name, SearchTargetKind.ChannelWatermark)));
result.AddRange(
dbContext.Collections
.Map(c => new SearchTargetViewModel(c.Id, c.Name, SearchTargetKind.Collection)));
result.AddRange(
dbContext.MultiCollections
.Map(mc => new SearchTargetViewModel(mc.Id, mc.Name, SearchTargetKind.MultiCollection)));
result.AddRange(
dbContext.SmartCollections
.Map(sc => new SmartCollectionSearchTargetViewModel(sc.Id, sc.Name, sc.Query)));
var schedules = await dbContext.ProgramSchedules
.Map(s => new { s.Id, s.Name })
.ToListAsync(cancellationToken);
result.AddRange(
schedules.SelectMany(
s => new[]
{
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.Schedule),
new SearchTargetViewModel(s.Id, s.Name, SearchTargetKind.ScheduleItems)
}));
return result;
}
}

18
ErsatzTV.Application/Search/SearchTargetResultViewModel.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
namespace ErsatzTV.Application.Search;
public record SearchTargetViewModel(int Id, string Name, SearchTargetKind Kind);
public record SmartCollectionSearchTargetViewModel(int Id, string Name, string Query)
: SearchTargetViewModel(Id, Name, SearchTargetKind.SmartCollection);
public enum SearchTargetKind
{
Channel = 1,
FFmpegProfile = 2,
ChannelWatermark = 3,
Collection = 4,
MultiCollection = 5,
SmartCollection = 6,
Schedule = 7,
ScheduleItems = 8
}

111
ErsatzTV/Shared/MainLayout.razor

@ -1,13 +1,16 @@ @@ -1,13 +1,16 @@
@using System.Reflection
@inherits LayoutComponentBase
@using System.Reflection
@using ErsatzTV.Extensions
@inherits LayoutComponentBase
@using ErsatzTV.Application.Search
@implements IDisposable
@inject NavigationManager _navigationManager
@inject IMediator _mediator
<MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/>
<MudSnackbarProvider/>
<MudLayout>
<MudLayout @onclick="@(() => _isOpen = false)">
<MudAppBar Elevation="1" Class="app-bar">
<div style="min-width: 240px">
<a href="/">
@ -16,12 +19,46 @@ @@ -16,12 +19,46 @@
</div>
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
<MudTextField T="string"
@bind-Value="@_query"
@bind-Value="@Query"
AdornmentIcon="@Icons.Material.Filled.Search"
Adornment="Adornment.Start"
Variant="Variant.Outlined"
Class="search-bar">
Immediate="true"
Class="search-bar"
@onclick="@(() => _isOpen = true)"
OnKeyUp="OnKeyUp">
</MudTextField>
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
{
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
if (matches.Any())
{
<MudList Clickable="true" Dense="true">
@foreach (SearchTargetViewModel searchTarget in matches)
{
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
@(searchTarget.Kind switch
{
SearchTargetKind.Channel => "Channel",
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
SearchTargetKind.ChannelWatermark => "Channel Watermark",
SearchTargetKind.Collection => "Collection",
SearchTargetKind.MultiCollection => "Multi Collection",
SearchTargetKind.SmartCollection => "Smart Collection",
SearchTargetKind.Schedule => "Schedule",
SearchTargetKind.ScheduleItems => "Schedule Items",
_ => string.Empty
})
</MudText>
</MudListItem>
}
</MudList>
}
}
</MudPopover>
</EditForm>
<MudSpacer/>
<MudLink Color="Color.Info" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
@ -81,11 +118,21 @@ @@ -81,11 +118,21 @@
@code {
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private readonly CancellationTokenSource _cts = new();
private string _query;
private record SearchModel;
private readonly SearchModel _dummyModel = new();
private bool _isOpen;
private List<SearchTargetViewModel> _searchTargets;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
private MudTheme _ersatzTvTheme => new()
{
@ -110,10 +157,31 @@ @@ -110,10 +157,31 @@
}
};
private string Query
{
get => _query;
set
{
if (_query == value)
{
return;
}
_query = value;
_isOpen = true;
StateHasChanged();
}
}
protected override async Task OnParametersSetAsync()
{
await base.OnParametersSetAsync();
_query = _navigationManager.Uri.GetSearchQuery();
if (_searchTargets is null)
{
_searchTargets = await _mediator.Send(new QuerySearchTargets(), _cts.Token);
}
}
private void PerformSearch()
@ -122,4 +190,37 @@ @@ -122,4 +190,37 @@
StateHasChanged();
}
private void OnKeyUp(KeyboardEventArgs args)
{
switch (args.Key)
{
case "Enter":
case "NumpadEnter":
_isOpen = false;
break;
case "Escape":
_isOpen = false;
break;
}
}
private void NavigateTo(SearchTargetViewModel searchTarget) =>
// need to force smart collections to navigate since the query string is all that differs
_navigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection);
private string UrlFor(SearchTargetViewModel searchTarget) =>
searchTarget.Kind switch
{
SearchTargetKind.Channel => $"channels/{searchTarget.Id}",
SearchTargetKind.FFmpegProfile => $"ffmpeg/{searchTarget.Id}",
SearchTargetKind.ChannelWatermark => $"watermarks/{searchTarget.Id}",
SearchTargetKind.Collection => $"media/collections/{searchTarget.Id}",
SearchTargetKind.MultiCollection => $"media/multi-collections/{searchTarget.Id}/edit",
SearchTargetKind.SmartCollection when searchTarget is SmartCollectionSearchTargetViewModel sc =>
sc.Query.GetRelativeSearchQuery(),
SearchTargetKind.Schedule => $"schedules/{searchTarget.Id}",
SearchTargetKind.ScheduleItems => $"schedules/{searchTarget.Id}/items",
_ => null
};
}
Loading…
Cancel
Save