From 21f4439aa4458f9cddbf9f6a9973775bf7a2c1d8 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:25:44 -0600 Subject: [PATCH] block ui improvements (#2646) * template editor improvements * more keyboard navigation * replace template tree view with template table --- CHANGELOG.md | 8 + .../Scheduling/Commands/CopyTemplate.cs | 6 + .../Commands/CopyTemplateHandler.cs | 100 +++++++ .../Scheduling/Queries/GetAllTemplates.cs | 3 + .../Queries/GetAllTemplatesHandler.cs | 54 ++++ .../Scheduling/Queries/GetTemplateTree.cs | 5 - .../Queries/GetTemplateTreeHandler.cs | 24 -- .../BlockPlayoutFillerBuilder.cs | 4 +- ErsatzTV/Pages/Blocks.razor | 10 +- ErsatzTV/Pages/TemplateEditor.razor | 35 ++- ErsatzTV/Pages/Templates.razor | 245 ++++++++++++------ ErsatzTV/Shared/CopyTemplateDialog.razor | 100 +++++++ 12 files changed, 477 insertions(+), 117 deletions(-) create mode 100644 ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs create mode 100644 ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs create mode 100644 ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs create mode 100644 ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs delete mode 100644 ErsatzTV.Application/Scheduling/Queries/GetTemplateTree.cs delete mode 100644 ErsatzTV.Application/Scheduling/Queries/GetTemplateTreeHandler.cs create mode 100644 ErsatzTV/Shared/CopyTemplateDialog.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e141cebd..61ae5aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add channel troubleshooting button to channels list - This will open the playback troubleshooting tool in "channel" mode - This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time +- Block schedules: add copy template button to templates table ### Fixed - Fix HLS Direct playback with Jellyfin 10.11 @@ -84,6 +85,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Use smaller batch size for search index updates (100, down from 1000) - This should help newly scanned items appear in the UI more quickly - Replace favicon and logo in background image used for error streams +- Block schedules: + - Auto scroll day view to block item time when adding and removing block items from template + - Allow keyboard selection of + - Block groups in block list + - Template groups in template list + - Block groups and blocks in template editor + - Replace template tree view with searchable table (like blocks) ## [25.8.0] - 2025-10-26 ### Added diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs b/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs new file mode 100644 index 000000000..9b971ba56 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Scheduling; + +public record CopyTemplate(int TemplateId, int NewTemplateGroupId, string NewTemplateName) + : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs new file mode 100644 index 000000000..645266e0a --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs @@ -0,0 +1,100 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Scheduling.Mapper; + +namespace ErsatzTV.Application.Scheduling; + +public class CopyTemplateHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + CopyTemplate request, + CancellationToken cancellationToken) + { + try + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request, cancellationToken); + return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken)); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + return BaseError.New(ex.Message); + } + } + + private static async Task PerformCopy( + TvContext dbContext, + Template template, + CopyTemplate request, + CancellationToken cancellationToken) + { + DetachEntity(dbContext, template); + template.Name = request.NewTemplateName; + template.TemplateGroup = null; + template.TemplateGroupId = request.NewTemplateGroupId; + + foreach (TemplateItem item in template.Items) + { + DetachEntity(dbContext, item); + item.TemplateId = 0; + item.Template = template; + } + + await dbContext.Templates.AddAsync(template, cancellationToken); + await dbContext.TemplateItems.AddRangeAsync(template.Items, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + + await dbContext.Entry(template).Reference(b => b.TemplateGroup).LoadAsync(cancellationToken); + + return ProjectToViewModel(template); + } + + private static async Task> Validate( + TvContext dbContext, + CopyTemplate request, + CancellationToken cancellationToken) => + (await TemplateMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) + .Apply((template, _) => template); + + private static Task> TemplateMustExist( + TvContext dbContext, + CopyTemplate request, + CancellationToken cancellationToken) => + dbContext.Templates + .AsNoTracking() + .Include(t => t.Items) + .SelectOneAsync(p => p.Id, p => p.Id == request.TemplateId, cancellationToken) + .Map(o => o.ToValidation("Template does not exist.")); + + private static async Task> ValidateName(TvContext dbContext, CopyTemplate request) + { + List allNames = await dbContext.Templates + .Where(b => b.TemplateGroupId == request.NewTemplateGroupId) + .Map(ps => ps.Name) + .ToListAsync(); + + Validation result1 = request.NotEmpty(c => c.NewTemplateName) + .Bind(_ => request.NotLongerThan(50)(c => c.NewTemplateName)); + + var result2 = Optional(request.NewTemplateName) + .Where(name => !allNames.Contains(name)) + .ToValidation("Template name must be unique within the template group."); + + return (result1, result2).Apply((_, _) => request.NewTemplateName); + } + + private static void DetachEntity(TvContext db, T entity) where T : class + { + db.Entry(entity).State = EntityState.Detached; + if (entity.GetType().GetProperty("Id") is not null) + { + entity.GetType().GetProperty("Id")!.SetValue(entity, 0); + } + } +} diff --git a/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs new file mode 100644 index 000000000..695cb40cd --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Scheduling; + +public record GetAllTemplates : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs new file mode 100644 index 000000000..f7220761f --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs @@ -0,0 +1,54 @@ +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Scheduling; + +public class GetAllTemplatesHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle(GetAllTemplates request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List