From 54fdc034eacb0ae981fb1092752aadc59eed5572 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 22 Jun 2026 20:58:20 +0100 Subject: [PATCH] Implemented the Projects listing redesign. --- PlotLine/Data/Repositories.cs | 62 ++++ PlotLine/Services/CoreServices.cs | 40 ++- PlotLine/ViewModels/CoreViewModels.cs | 21 +- PlotLine/Views/Projects/Index.cshtml | 271 +++++++++++++----- PlotLine/wwwroot/css/plotline-theme.css | 299 ++++++++++++++++++++ PlotLine/wwwroot/css/plotline-theme.min.css | 2 +- 6 files changed, 618 insertions(+), 77 deletions(-) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index eed3121..b651e7e 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -51,6 +51,7 @@ public interface IProjectActivityRepository public interface IBookRepository { Task> ListByProjectAsync(int projectId); + Task> ListByProjectsAsync(IEnumerable projectIds); Task GetAsync(int bookId); Task GetCoverPathsAsync(int bookId); Task SaveAsync(Book book); @@ -2374,6 +2375,67 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB return rows.ToList(); } + public async Task> ListByProjectsAsync(IEnumerable projectIds) + { + var ids = projectIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return []; + } + + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT + b.BookID, + b.ProjectID, + b.BookTitle, + b.Subtitle, + b.Tagline, + b.ShortDescription, + b.Status, + b.BookNumber, + b.Description, + b.TargetWordCount, + b.WritingStartDate, + b.TargetCompletionDate, + b.PublicationDate, + b.CoverThumbnailPath, + b.CoverStandardPath, + ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount, + ISNULL(counts.ChapterCount, 0) AS ChapterCount, + ISNULL(counts.SceneCount, 0) AS SceneCount, + b.SortOrder, + b.CreatedDate, + b.UpdatedDate, + b.IsArchived, + b.ArchivedDate, + b.ArchivedReason + FROM dbo.Books b + OUTER APPLY + ( + SELECT + COUNT(DISTINCT c.ChapterID) AS ChapterCount, + COUNT(s.SceneID) AS SceneCount + FROM dbo.Chapters c + LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) counts + OUTER APPLY + ( + SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount + FROM dbo.Chapters c + INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE c.BookID = b.BookID AND c.IsArchived = 0 + ) wordCounts + WHERE b.ProjectID IN @ProjectIDs AND b.IsArchived = 0 + ORDER BY b.ProjectID, b.SortOrder, b.BookNumber, b.BookTitle; + """, + new { ProjectIDs = ids }); + return rows.ToList(); + } + public async Task GetAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index ee1e980..8490f07 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -411,14 +411,50 @@ public sealed class ProjectService( return new ProjectIndexViewModel(); } + var activeProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value); + var archivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value); + var projectBooks = await books.ListByProjectsAsync(activeProjects.Concat(archivedProjects).Select(project => project.ProjectID)); + var booksByProject = projectBooks + .GroupBy(book => book.ProjectID) + .ToDictionary(group => group.Key, group => group.ToList()); + return new ProjectIndexViewModel { - ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value), - ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value), + ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), + ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow) }; } + private static ProjectIndexCardViewModel ToIndexCard(Project project, IReadOnlyDictionary> booksByProject) + { + booksByProject.TryGetValue(project.ProjectID, out var projectBooks); + projectBooks ??= []; + + return new ProjectIndexCardViewModel + { + ProjectID = project.ProjectID, + ProjectName = project.ProjectName, + Description = project.Description, + CreatedDate = project.CreatedDate, + UpdatedDate = project.UpdatedDate, + IsArchived = project.IsArchived, + ArchivedDate = project.ArchivedDate, + AccessRole = project.AccessRole, + BookCount = projectBooks.Count, + TotalWords = projectBooks.Sum(book => book.CurrentWordCount), + LastBookUpdatedDate = projectBooks.Count == 0 ? null : projectBooks.Max(book => book.UpdatedDate), + CoverThumbnailPaths = projectBooks + .Where(book => !string.IsNullOrWhiteSpace(book.CoverThumbnailPath)) + .OrderBy(book => book.SortOrder) + .ThenBy(book => book.BookNumber) + .ThenBy(book => book.BookTitle) + .Select(book => book.CoverThumbnailPath!) + .Take(3) + .ToList() + }; + } + public async Task GetDetailAsync(int projectId) { if (!currentUser.UserId.HasValue) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 6204c0d..43241b2 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -23,11 +23,28 @@ public sealed class ProjectEditViewModel public sealed class ProjectIndexViewModel { - public IReadOnlyList ActiveProjects { get; init; } = []; - public IReadOnlyList ArchivedProjects { get; init; } = []; + public IReadOnlyList ActiveProjects { get; init; } = []; + public IReadOnlyList ArchivedProjects { get; init; } = []; public TrialVisibilityViewModel? Trial { get; init; } } +public sealed class ProjectIndexCardViewModel +{ + public int ProjectID { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string? Description { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } + public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string AccessRole { get; set; } = "Owner"; + public bool IsOwner => string.Equals(AccessRole, "Owner", StringComparison.OrdinalIgnoreCase); + public int BookCount { get; set; } + public int TotalWords { get; set; } + public DateTime? LastBookUpdatedDate { get; set; } + public IReadOnlyList CoverThumbnailPaths { get; set; } = []; +} + public sealed class ProjectDetailViewModel { public Project Project { get; set; } = new(); diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index 0512542..e51c2db 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -56,82 +56,195 @@ } else { -
-

Active Projects

- @if (!Model.ActiveProjects.Any()) - { -

No active projects.

- } - else - { -
- @foreach (var project in Model.ActiveProjects) - { -
-

@project.ProjectName

-

@project.AccessRole

-

@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)

-
- Open - Edit - @if (project.IsOwner) - { - Download backup -
- - -
- } -
-
- } -
- } -
+