add multiselect and movie tags (#62)
* add basic selection behavior to search results * add search scrolling, selection actions * include shows in multiselect * multiselect movies, shows, collection items * add movie and show tags * code cleanup * update show screenshot
This commit is contained in:
@@ -18,9 +18,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
|
||||
GetSearchCards request,
|
||||
CancellationToken cancellationToken) =>
|
||||
request.Query.StartsWith("genre:")
|
||||
? GenreSearch(request.Query.Replace("genre:", string.Empty))
|
||||
: TitleSearch(request.Query);
|
||||
request.Query.Split(":").Head() switch
|
||||
{
|
||||
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
|
||||
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
|
||||
_ => TitleSearch(request.Query)
|
||||
};
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
|
||||
@@ -31,5 +34,10 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Plot,
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList());
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList());
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
|
||||
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Movies
|
||||
string Plot,
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres);
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()));
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Television
|
||||
string Plot,
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres);
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,5 +16,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public DateTime DateUpdated { get; set; }
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
public List<Genre> Genres { get; set; }
|
||||
public List<Tag> Tags { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Tag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,5 +8,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
|
||||
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
|
||||
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -146,6 +146,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
}
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -188,6 +200,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
}
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -250,7 +274,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
Tagline = nfo.Tagline,
|
||||
Year = nfo.Year,
|
||||
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList()
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -306,7 +331,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList()
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -358,6 +384,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("genre")]
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("tvshow")]
|
||||
@@ -383,6 +412,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("genre")]
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
|
||||
@@ -17,6 +17,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
@@ -37,6 +43,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(m => m.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(m => m.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(m => m.Tags)
|
||||
.OrderBy(m => m.Id)
|
||||
.SingleOrDefaultAsync(m => m.Id == movieId)
|
||||
.Map(Optional);
|
||||
@@ -49,6 +57,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(i => i.LibraryPath)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
|
||||
@@ -72,5 +72,32 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.OfType<MediaItem>()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT M.Id FROM Movie M
|
||||
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
|
||||
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
|
||||
WHERE T.Name LIKE @Query
|
||||
UNION
|
||||
SELECT S.Id FROM Show S
|
||||
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
|
||||
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
|
||||
WHERE T2.Name LIKE @Query
|
||||
GROUP BY SM.Title, SM.Year",
|
||||
new { Query = tag })
|
||||
.Map(results => results.ToList());
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return await context.MediaItems
|
||||
.Filter(m => ids.Contains(m.Id))
|
||||
.Include(m => (m as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => (m as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.OfType<MediaItem>()
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -57,6 +63,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
@@ -175,6 +183,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync(s => s.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -188,6 +198,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
metadata.DateAdded = DateTime.UtcNow;
|
||||
metadata.Genres ??= new List<Genre>();
|
||||
metadata.Tags ??= new List<Tag>();
|
||||
var show = new Show
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
|
||||
+1623
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MetadataTags : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
"Tag",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>("TEXT", nullable: true),
|
||||
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tag", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
x => x.EpisodeMetadataId,
|
||||
"EpisodeMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_MovieMetadata_MovieMetadataId",
|
||||
x => x.MovieMetadataId,
|
||||
"MovieMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
x => x.SeasonMetadataId,
|
||||
"SeasonMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Tag_ShowMetadata_ShowMetadataId",
|
||||
x => x.ShowMetadataId,
|
||||
"ShowMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_EpisodeMetadataId",
|
||||
"Tag",
|
||||
"EpisodeMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_MovieMetadataId",
|
||||
"Tag",
|
||||
"MovieMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_SeasonMetadataId",
|
||||
"Tag",
|
||||
"SeasonMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Tag_ShowMetadataId",
|
||||
"Tag",
|
||||
"ShowMetadataId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropTable(
|
||||
"Tag");
|
||||
}
|
||||
}
|
||||
Generated
+1623
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_MetadataDateUpdated_Tags : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,6 +839,42 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("ShowMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EpisodeMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MovieMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SeasonMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ShowMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EpisodeMetadataId");
|
||||
|
||||
b.HasIndex("MovieMetadataId");
|
||||
|
||||
b.HasIndex("SeasonMetadataId");
|
||||
|
||||
b.HasIndex("ShowMetadataId");
|
||||
|
||||
b.ToTable("Tag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.LocalLibrary",
|
||||
b =>
|
||||
@@ -1421,6 +1457,29 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Show");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("EpisodeMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("MovieMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("SeasonMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ShowMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.LocalLibrary",
|
||||
b =>
|
||||
@@ -1609,6 +1668,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); });
|
||||
@@ -1628,6 +1689,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
@@ -1655,6 +1718,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
@@ -1664,6 +1729,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Artwork");
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
|
||||
@@ -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>
|
||||
<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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
|
||||
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 @@
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Extensions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace ErsatzTV.Pages
|
||||
{
|
||||
public class FragmentNavigationBase : ComponentBase, IDisposable
|
||||
{
|
||||
[Inject]
|
||||
private NavigationManager NavManager { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JsRuntime { get; set; }
|
||||
|
||||
public void Dispose() => NavManager.LocationChanged -= TryFragmentNavigation;
|
||||
|
||||
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
}
|
||||
|
||||
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args) =>
|
||||
await NavManager.NavigateToFragmentAsync(JsRuntime);
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,21 @@
|
||||
</div>
|
||||
@if (_movie.Genres.Any())
|
||||
{
|
||||
<div>
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _movie.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_movie.Tags.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Tags</MudText>
|
||||
<div>
|
||||
@foreach (string tag in _movie.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -5,15 +5,34 @@
|
||||
@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 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"
|
||||
@@ -27,14 +46,20 @@
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
</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 @@
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-12
@@ -6,42 +6,80 @@
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<Search>
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<Search> 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.h4">Search Results: "@_query"</MudText>
|
||||
<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
|
||||
{
|
||||
<MudText>@_query</MudText>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
||||
@if (_data?.MovieCards.Any() == true)
|
||||
{
|
||||
<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}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
AddToCollectionClicked="@AddToCollection"
|
||||
SelectClicked="@(e => SelectClicked(card, e))"
|
||||
IsSelected="@IsSelected(card)"
|
||||
IsSelectMode="@IsSelectMode()"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data?.ShowCards.Any() == true)
|
||||
{
|
||||
<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" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.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>
|
||||
}
|
||||
@@ -63,6 +101,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
SelectClicked(GetSortedItems, card, e);
|
||||
}
|
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card)
|
||||
{
|
||||
if (card is MovieCardViewModel movie)
|
||||
|
||||
@@ -60,10 +60,21 @@
|
||||
</div>
|
||||
@if (_show.Genres.Any())
|
||||
{
|
||||
<div>
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _show.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_show.Tags.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Tags</MudText>
|
||||
<div>
|
||||
@foreach (string tag in _show.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 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)">
|
||||
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
|
||||
@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)">
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
</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 static int PageSize => 100;
|
||||
|
||||
[Parameter]
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionShowCardResultsViewModel _data;
|
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData();
|
||||
|
||||
private async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetTelevisionShowCards(_pageNumber, _pageSize));
|
||||
|
||||
private async Task PrevPage()
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
_pageNumber -= 1;
|
||||
await RefreshData();
|
||||
if (PageNumber == 0)
|
||||
{
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
private async Task NextPage()
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
{
|
||||
ActionDefault = "rgba(255,255,255, 0.80)",
|
||||
Primary = "#009000",
|
||||
Secondary = "#009090",
|
||||
AppbarBackground = "#121212",
|
||||
Background = "#272727",
|
||||
DrawerBackground = "#1f1f1f",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")">
|
||||
@if (!string.IsNullOrWhiteSpace(Link))
|
||||
{
|
||||
<div style="position: relative">
|
||||
<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))
|
||||
{
|
||||
@@ -14,19 +15,36 @@
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
@if (IsSelected)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
<div class="media-card-overlay" style="">
|
||||
<MudButton Link="@Link" Style="height: 100%; width: 100%">
|
||||
<MudButton Link="@(IsSelectMode ? null : Link)"
|
||||
Style="height: 100%; width: 100%"
|
||||
OnClick="@(e => IsSelectMode ? SelectClicked.InvokeAsync(e) : Task.CompletedTask)">
|
||||
</MudButton>
|
||||
@if (AddToCollectionClicked.HasDelegate)
|
||||
@if (SelectClicked.HasDelegate)
|
||||
{
|
||||
<MudIconButton Color="@SelectColor"
|
||||
Icon="@(IsSelected ? Icons.Material.Filled.CheckBox : Icons.Material.Filled.CheckBoxOutlineBlank)"
|
||||
Style="left: 0; position: absolute; top: 0;"
|
||||
OnClick="@(e => SelectClicked.InvokeAsync(e))"/>
|
||||
}
|
||||
@if (AddToCollectionClicked.HasDelegate && !IsSelectMode)
|
||||
{
|
||||
<MudIconButton Color="Color.Tertiary"
|
||||
Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||
Style="bottom: 0; left: 0; position: absolute;"
|
||||
OnClick="@(() => AddToCollectionClicked.InvokeAsync(Data))"/>
|
||||
}
|
||||
@if (DeleteClicked.HasDelegate)
|
||||
@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))"/>
|
||||
@@ -88,6 +106,18 @@
|
||||
[Parameter]
|
||||
public EventCallback<MediaCardViewModel> AddToCollectionClicked { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> SelectClicked { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSelectMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsSelected { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public Color SelectColor { get; set; } = Color.Tertiary;
|
||||
|
||||
private string GetPlaceholder(string sortTitle)
|
||||
{
|
||||
if (Placeholder != null)
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
width: 152px;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.media-card:hover { /*filter: brightness(75%);*/ }
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 435 KiB |
Reference in New Issue
Block a user