Browse Source

multiselect movies, shows, collection items

pull/62/head
Jason Dove 5 years ago
parent
commit
8f10e75e5a
  1. 9
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  2. 80
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  3. 2
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  4. 2
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  5. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  6. 1
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  7. 1
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  8. 28
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  9. 6
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  10. 6
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  11. 127
      ErsatzTV/Pages/CollectionItems.razor
  12. 81
      ErsatzTV/Pages/MovieList.razor
  13. 159
      ErsatzTV/Pages/MultiSelectBase.cs
  14. 63
      ErsatzTV/Pages/Search.razor
  15. 109
      ErsatzTV/Pages/TelevisionShowList.razor
  16. 16
      ErsatzTV/Shared/MediaCard.razor
  17. 6
      ErsatzTV/wwwroot/css/site.css

9
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

80
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddItemsRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToCollection request) =>
_televisionRepository.AllShowsExist(request.ShowIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}

2
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -16,6 +16,8 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -16,6 +16,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds) => throw new NotSupportedException();
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();

2
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public class FakeTelevisionRepository : ITelevisionRepository
{
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException();

1
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
Task<Collection> Add(Collection collection);
Task<bool> AddMediaItem(int collectionId, int mediaItemId);
Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds);
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);

1
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMovieRepository
{
Task<bool> AllMoviesExist(List<int> movieIds);
Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);

1
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ITelevisionRepository
{
Task<bool> AllShowsExist(List<int> showIds);
Task<bool> Update(Show show);
Task<bool> Update(Season season);
Task<bool> Update(Episode episode);

28
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -62,6 +62,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -62,6 +62,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return modified;
}
public async Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds)
{
var modified = false;
Option<Collection> maybeCollection = await _dbContext.Collections
.Include(c => c.MediaItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == collectionId)
.Map(Optional);
await maybeCollection.IfSomeAsync(
async collection =>
{
var toAdd = mediaItemIds.Filter(i => collection.MediaItems.All(i2 => i2.Id != i)).ToList();
if (toAdd.Any())
{
List<MediaItem> items = await _dbContext.MediaItems
.Filter(mi => toAdd.Contains(mi.Id))
.ToListAsync();
collection.MediaItems.AddRange(items);
modified = await _dbContext.SaveChangesAsync() > 0;
}
});
return modified;
}
public Task<Option<Collection>> Get(int id) =>
_dbContext.Collections
.OrderBy(c => c.Id)

6
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -29,6 +29,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -29,6 +29,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> AllMoviesExist(List<int> movieIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Movie WHERE Id in @MovieIds",
new { MovieIds = movieIds })
.Map(c => c == movieIds.Count);
public async Task<Option<Movie>> GetMovie(int movieId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();

6
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -24,6 +24,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -24,6 +24,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> AllShowsExist(List<int> showIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Show WHERE Id in @ShowIds",
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public async Task<bool> Update(Show show)
{
_dbContext.Shows.Update(show);

127
ErsatzTV/Pages/CollectionItems.razor

@ -2,52 +2,114 @@ @@ -2,52 +2,114 @@
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections.Commands
@inherits MultiSelectBase<CollectionItems>
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject ILogger<CollectionItems> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/media/collections/{Id}/edit")"
Style="margin-bottom: auto; margin-top: auto;"/>
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="align-items: center; display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%;" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Remove"
OnClick="@(_ => RemoveSelectionFromCollection(Id))">
Remove From Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<div style="display: flex; flex-direction: row;">
<MudText Typo="Typo.h4">@_data.Name</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/media/collections/{Id}/edit")"
Style="margin-bottom: auto; margin-top: auto;"/>
</div>
@if (_data.MovieCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
}
@if (_data.ShowCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
}
@if (_data.SeasonCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#seasons")">@_data.SeasonCards.Count Seasons</MudLink>
}
@if (_data.EpisodeCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
@if (_data.MovieCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@RemoveMovieFromCollection"/>
DeleteClicked="@RemoveMovieFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.ShowCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Television Shows
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@RemoveShowFromCollection"/>
DeleteClicked="@RemoveShowFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.SeasonCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Television Seasons
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
@ -56,14 +118,23 @@ @@ -56,14 +118,23 @@
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
Title="@card.ShowTitle"
Subtitle="@card.Title"
DeleteClicked="@RemoveSeasonFromCollection"/>
DeleteClicked="@RemoveSeasonFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.EpisodeCards.Any())
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Television Episodes
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
@ -75,7 +146,11 @@ @@ -75,7 +146,11 @@
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
DeleteClicked="@(_ => RemoveEpisodeFromCollection(card))"
ArtworkKind="@ArtworkKind.Thumbnail"/>
ArtworkKind="@ArtworkKind.Thumbnail"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@ -90,7 +165,7 @@ @@ -90,7 +165,7 @@
protected override async Task OnParametersSetAsync() => await RefreshData();
private async Task RefreshData()
protected override async Task RefreshData()
{
Either<BaseError, CollectionCardResultsViewModel> maybeResult =
await Mediator.Send(new GetCollectionCards(Id));
@ -100,6 +175,20 @@ @@ -100,6 +175,20 @@
error => NavigationManager.NavigateTo("404"));
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task RemoveMovieFromCollection(MediaCardViewModel vm)
{
if (vm is MovieCardViewModel movie)

81
ErsatzTV/Pages/MovieList.razor

@ -5,36 +5,61 @@ @@ -5,36 +5,61 @@
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inject ILogger<MovieList> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inherits MultiSelectBase<MovieList>
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"/>
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
</MudContainer>
@ -57,13 +82,23 @@ @@ -57,13 +82,23 @@
return RefreshData();
}
private async Task RefreshData() =>
protected override async Task RefreshData() =>
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize));
private void PrevPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber - 1}");
private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}");
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MovieCardViewModel movie)

159
ErsatzTV/Pages/MultiSelectBase.cs

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaCollections.Commands;
using ErsatzTV.Core;
using ErsatzTV.Shared;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Logging;
using MudBlazor;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Pages
{
public class MultiSelectBase<T> : FragmentNavigationBase
{
private readonly System.Collections.Generic.HashSet<MediaCardViewModel> _selectedItems;
private Option<MediaCardViewModel> _recentlySelected;
public MultiSelectBase()
{
_recentlySelected = None;
_selectedItems = new System.Collections.Generic.HashSet<MediaCardViewModel>();
}
[Inject]
protected IDialogService Dialog { get; set; }
[Inject]
protected ISnackbar Snackbar { get; set; }
[Inject]
protected ILogger<T> Logger { get; set; }
[Inject]
protected IMediator Mediator { get; set; }
protected bool IsSelected(MediaCardViewModel card) =>
_selectedItems.Contains(card);
protected bool IsSelectMode() =>
_selectedItems.Any();
protected string SelectionLabel() =>
$"{_selectedItems.Count} {(_selectedItems.Count == 1 ? "Item" : "Items")} Selected";
protected void ClearSelection()
{
_selectedItems.Clear();
_recentlySelected = None;
}
protected virtual Task RefreshData() => Task.CompletedTask;
protected void SelectClicked(
Func<List<MediaCardViewModel>> getSortedItems,
MediaCardViewModel card,
MouseEventArgs e)
{
if (_selectedItems.Contains(card))
{
_selectedItems.Remove(card);
}
else
{
if (e.ShiftKey && _recentlySelected.IsSome)
{
List<MediaCardViewModel> sorted = getSortedItems();
int start = sorted.IndexOf(_recentlySelected.ValueUnsafe());
int finish = sorted.IndexOf(card);
if (start > finish)
{
int temp = start;
start = finish;
finish = temp;
}
for (int i = start; i < finish; i++)
{
_selectedItems.Add(sorted[i]);
}
}
_recentlySelected = card;
_selectedItems.Add(card);
}
}
protected async Task AddSelectionToCollection()
{
var parameters = new DialogParameters
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddItemsToCollection(
collection.Id,
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding items to collection: {error.Value}");
Logger.LogError("Unexpected error adding items to collection: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add(
$"Added {_selectedItems.Count} items to collection {collection.Name}",
Severity.Success);
ClearSelection();
});
}
}
protected async Task RemoveSelectionFromCollection(int collectionId)
{
var parameters = new DialogParameters
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<RemoveFromCollectionDialog>(
"Remove From Collection",
parameters,
options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
var itemIds = new List<int>();
itemIds.AddRange(_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId));
itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId));
itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId));
itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId));
await Mediator.Send(
new RemoveItemsFromCollection(collectionId)
{
MediaItemIds = itemIds
});
await RefreshData();
ClearSelection();
}
}
}
}

63
ErsatzTV/Pages/Search.razor

@ -6,23 +6,20 @@ @@ -6,23 +6,20 @@
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using Unit = LanguageExt.Unit
@inherits FragmentNavigationBase
@inherits MultiSelectBase<Search>
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject ILogger<Search> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudPaper Square="true" Style="padding: 0; position: fixed; left: 240px; right: 0; z-index: 100; height: 64px; display: flex">
<div style="display: flex; flex-direction: row; margin-top: auto; margin-bottom: auto; width: 100%" class="ml-6 mr-6">
@if (_selectedCards.Any())
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@_selectedCards.Count @(_selectedCards.Count == 1 ? "Item" : "Items") Selected</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add">
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
@ -59,8 +56,8 @@ @@ -59,8 +56,8 @@
Link="@($"/media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@_selectedCards.Contains(card)"
IsSelectMode="@_selectedCards.Any()"/>
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@ -81,8 +78,8 @@ @@ -81,8 +78,8 @@
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@_selectedCards.Contains(card)"
IsSelectMode="@_selectedCards.Any()"/>
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@ -91,8 +88,6 @@ @@ -91,8 +88,6 @@
@code {
private string _query;
private SearchCardResultsViewModel _data;
private System.Collections.Generic.HashSet<MediaCardViewModel> _selectedCards;
private MediaCardViewModel _selectedLast;
protected override async Task OnInitializedAsync()
{
@ -104,44 +99,18 @@ @@ -104,44 +99,18 @@
Either<BaseError, SearchCardResultsViewModel> maybeResults = await Mediator.Send(new GetSearchCards(_query));
maybeResults.IfRight(results => _data = results);
}
_selectedCards = new System.Collections.Generic.HashSet<MediaCardViewModel>();
}
private void ClearSelection() => _selectedCards.Clear();
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
if (_selectedCards.Contains(card))
List<MediaCardViewModel> GetSortedItems()
{
_selectedCards.Remove(card);
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.ToList();
}
else
{
if (e.ShiftKey && _selectedLast != null)
{
var sorted = _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.ToList();
int start = sorted.IndexOf(_selectedLast);
int finish = sorted.IndexOf(card);
if (start > finish)
{
int temp = start;
start = finish;
finish = temp;
}
for (int i = start; i < finish; i++)
{
_selectedCards.Add(sorted[i]);
}
}
_selectedLast = card;
_selectedCards.Add(card);
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)

109
ErsatzTV/Pages/TelevisionShowList.razor

@ -1,63 +1,102 @@ @@ -1,63 +1,102 @@
@page "/media/tv/shows"
@page "/media/tv/shows/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inject ILogger<TelevisionShowList> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inherits MultiSelectBase<TelevisionShowList>
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@(() => PrevPage())"
Disabled="@(_pageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((_pageNumber - 1) * _pageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * _pageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * _pageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"/>
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
</MudContainer>
@code {
private int _pageSize => 100;
private int _pageNumber = 1;
private TelevisionShowCardResultsViewModel _data;
private static int PageSize => 100;
protected override Task OnParametersSetAsync() => RefreshData();
[Parameter]
public int PageNumber { get; set; }
private async Task RefreshData() =>
_data = await Mediator.Send(new GetTelevisionShowCards(_pageNumber, _pageSize));
private TelevisionShowCardResultsViewModel _data;
private async Task PrevPage()
protected override Task OnParametersSetAsync()
{
_pageNumber -= 1;
await RefreshData();
if (PageNumber == 0)
{
PageNumber = 1;
}
return RefreshData();
}
private async Task NextPage()
protected override async Task RefreshData() =>
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize));
private void PrevPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber - 1}");
private void NextPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber + 1}");
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
_pageNumber += 1;
await RefreshData();
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)

16
ErsatzTV/Shared/MediaCard.razor

@ -5,7 +5,8 @@ @@ -5,7 +5,8 @@
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")">
@if (!string.IsNullOrWhiteSpace(Link))
{
<div class="@(IsSelected ? "media-card-selected" : "")" style="position: relative; border-radius: 4px;">
<div class="@(IsSelected ? DeleteClicked.HasDelegate ? "media-card-selected-delete" : "media-card-selected" : "")"
style="border-radius: 4px; position: relative;">
<MudPaper Class="@($"media-card {CardClass}")" Style="@ArtworkForItem()">
@if (string.IsNullOrWhiteSpace(Data.Poster))
{
@ -16,8 +17,8 @@ @@ -16,8 +17,8 @@
</MudPaper>
@if (IsSelected)
{
<div style="height: 48px; width: 48px; top: 0; left: 0; position: absolute; display: flex">
<MudIcon Color="Color.Tertiary"
<div style="display: flex; height: 48px; left: 0; position: absolute; top: 0; width: 48px;">
<MudIcon Color="@SelectColor"
Icon="@Icons.Material.Filled.CheckBox"
Style="margin: auto"/>
</div>
@ -29,9 +30,9 @@ @@ -29,9 +30,9 @@
</MudButton>
@if (SelectClicked.HasDelegate)
{
<MudIconButton Color="Color.Tertiary"
<MudIconButton Color="@SelectColor"
Icon="@(IsSelected ? Icons.Material.Filled.CheckBox : Icons.Material.Filled.CheckBoxOutlineBlank)"
Style="top: 0; left: 0; position: absolute;"
Style="left: 0; position: absolute; top: 0;"
OnClick="@(e => SelectClicked.InvokeAsync(e))"/>
}
@if (AddToCollectionClicked.HasDelegate && !IsSelectMode)
@ -43,7 +44,7 @@ @@ -43,7 +44,7 @@
}
@if (DeleteClicked.HasDelegate && !IsSelectMode)
{
<MudIconButton Color="Color.Tertiary"
<MudIconButton Color="Color.Error"
Icon="@Icons.Material.Filled.Delete"
Style="bottom: 0; position: absolute; right: 0;"
OnClick="@(() => DeleteClicked.InvokeAsync(Data))"/>
@ -114,6 +115,9 @@ @@ -114,6 +115,9 @@
[Parameter]
public bool IsSelected { get; set; }
[Parameter]
public Color SelectColor { get; set; } = Color.Tertiary;
private string GetPlaceholder(string sortTitle)
{
if (Placeholder != null)

6
ErsatzTV/wwwroot/css/site.css

@ -18,9 +18,9 @@ @@ -18,9 +18,9 @@
width: 152px;
}
.media-card-selected {
box-shadow: 0 0 0 3px #00c000, 0 0 4px rgba(0, 0, 0, 0.3);
}
.media-card-selected { box-shadow: 0 0 0 3px #00c000, 0 0 4px rgba(0, 0, 0, 0.3); }
.media-card-selected-delete { box-shadow: 0 0 0 3px #f44336, 0 0 4px rgba(0, 0, 0, 0.3); }
.media-card-episode { width: 392px; }

Loading…
Cancel
Save