diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs index fe8e490..e4558c1 100644 --- a/PlotLine/Controllers/BooksController.cs +++ b/PlotLine/Controllers/BooksController.cs @@ -42,10 +42,10 @@ public sealed class BooksController(IBookService books) : Controller return View("Edit", model); } - int bookId; + BookSaveResult result; try { - bookId = await books.SaveAsync(model); + result = await books.SaveAsync(model); } catch (SubscriptionLimitException ex) when (model.BookID == 0) { @@ -71,7 +71,15 @@ public sealed class BooksController(IBookService books) : Controller return View("Edit", createModel); } - return RedirectToAction(nameof(Details), new { id = bookId }); + if (!result.Succeeded) + { + model.BookID = result.BookID; + ModelState.AddModelError(nameof(model.CoverUpload), result.ErrorMessage ?? "Cover image could not be uploaded."); + await books.PopulateEditContextAsync(model); + return View("Edit", model); + } + + return RedirectToAction(nameof(Details), new { id = result.BookID }); } [HttpPost] diff --git a/PlotLine/Data/ProjectBackupRepository.cs b/PlotLine/Data/ProjectBackupRepository.cs index 2a0a997..6acd57e 100644 --- a/PlotLine/Data/ProjectBackupRepository.cs +++ b/PlotLine/Data/ProjectBackupRepository.cs @@ -77,6 +77,7 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact var bookMetadata = (await connection.QueryAsync( """ SELECT BookID, Subtitle, Tagline, ShortDescription, Status, TargetWordCount, WritingStartDate, TargetCompletionDate, PublicationDate + , CoverThumbnailPath, CoverStandardPath FROM dbo.Books WHERE ProjectID = @ProjectID; """, @@ -96,6 +97,8 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact book.WritingStartDate = metadata.WritingStartDate; book.TargetCompletionDate = metadata.TargetCompletionDate; book.PublicationDate = metadata.PublicationDate; + book.CoverThumbnailPath = metadata.CoverThumbnailPath; + book.CoverStandardPath = metadata.CoverStandardPath; } return new ProjectBackupExport diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 28ce6bd..8eda1fd 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -49,7 +49,9 @@ public interface IBookRepository { Task> ListByProjectAsync(int projectId); Task GetAsync(int bookId); + Task GetCoverPathsAsync(int bookId); Task SaveAsync(Book book); + Task UpdateCoverAsync(int bookId, string? thumbnailPath, string? standardPath); Task ArchiveAsync(int bookId); } @@ -2036,6 +2038,18 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB return await connection.QuerySingleOrDefaultAsync("dbo.Book_Get", new { BookID = bookId }, commandType: CommandType.StoredProcedure); } + public async Task GetCoverPathsAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + """ + SELECT BookID, ProjectID, CoverThumbnailPath, CoverStandardPath + FROM dbo.Books + WHERE BookID = @BookID; + """, + new { BookID = bookId }); + } + public async Task SaveAsync(Book book) { using var connection = connectionFactory.CreateConnection(); @@ -2060,6 +2074,15 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB commandType: CommandType.StoredProcedure); } + public async Task UpdateCoverAsync(int bookId, string? thumbnailPath, string? standardPath) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Book_UpdateCover", + new { BookID = bookId, CoverThumbnailPath = thumbnailPath, CoverStandardPath = standardPath }, + commandType: CommandType.StoredProcedure); + } + public async Task ArchiveAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 237a206..5605712 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -202,6 +202,8 @@ public sealed class Book public DateTime? WritingStartDate { get; set; } public DateTime? TargetCompletionDate { get; set; } public DateTime? PublicationDate { get; set; } + public string? CoverThumbnailPath { get; set; } + public string? CoverStandardPath { get; set; } public int CurrentWordCount { get; set; } public int ChapterCount { get; set; } public int SceneCount { get; set; } diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index ac41658..fade980 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -11,6 +11,8 @@ + + diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index e6cd0ef..76956e4 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -115,6 +115,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/BookCoverService.cs b/PlotLine/Services/BookCoverService.cs new file mode 100644 index 0000000..6b7fba7 --- /dev/null +++ b/PlotLine/Services/BookCoverService.cs @@ -0,0 +1,274 @@ +using SkiaSharp; +using System.Collections.Concurrent; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IBookCoverService +{ + Task UploadCoverAsync(Book book, IFormFile upload); + Task DeleteCoverFilesAsync(Book book); +} + +public sealed record BookCoverUploadResult(bool Succeeded, string? ErrorMessage = null); + +public sealed class BookCoverService( + IWebHostEnvironment environment, + IProjectCollaborationRepository collaboration, + ISubscriptionService subscriptions, + IUserFileRepository userFiles, + IBookRepository books, + ILogger logger) : IBookCoverService +{ + public const string PlaceholderPath = "/images/placeholders/book-cover.svg"; + private const long MaxUploadBytes = 10L * 1024L * 1024L; + private const string ThumbnailFileName = "cover-thumb.webp"; + private const string StandardFileName = "cover-standard.webp"; + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" }; + private static readonly ConcurrentDictionary BookLocks = new(); + + public async Task UploadCoverAsync(Book book, IFormFile upload) + { + var uploadLock = BookLocks.GetOrAdd(book.BookID, _ => new SemaphoreSlim(1, 1)); + await uploadLock.WaitAsync(); + try + { + return await UploadCoverCoreAsync(book, upload); + } + finally + { + uploadLock.Release(); + } + } + + private async Task UploadCoverCoreAsync(Book book, IFormFile upload) + { + if (upload.Length <= 0) + { + return new(false, "Choose a cover image to upload."); + } + + if (upload.Length > MaxUploadBytes) + { + return new(false, "Cover image must be 10 MB or smaller."); + } + + if (!SupportedExtensions.Contains(Path.GetExtension(upload.FileName))) + { + return new(false, "Cover image must be a JPG, PNG or WebP file."); + } + + var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID); + if (!ownerUserId.HasValue) + { + return new(false, "Book owner could not be found."); + } + + var storageLimit = await subscriptions.CanUploadFileAsync(ownerUserId.Value, EstimatedGeneratedSize(upload.Length)); + if (!storageLimit.Allowed) + { + return new(false, storageLimit.Message); + } + + SKBitmap bitmap; + try + { + await using var uploadStream = upload.OpenReadStream(); + using var memory = new MemoryStream(); + await uploadStream.CopyToAsync(memory); + memory.Position = 0; + + using var codec = SKCodec.Create(memory) ?? throw new InvalidDataException(); + if (codec.EncodedFormat is not (SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Png or SKEncodedImageFormat.Webp)) + { + return new(false, "Cover image must be a valid JPG, PNG or WebP file."); + } + + memory.Position = 0; + bitmap = SKBitmap.Decode(memory) ?? throw new InvalidDataException(); + } + catch (Exception ex) when (ex is InvalidDataException or ArgumentException) + { + return new(false, "Cover image must be a valid JPG, PNG or WebP file."); + } + + using (bitmap) + { + if (bitmap.Width <= 0 || bitmap.Height <= 0) + { + return new(false, "Cover image could not be read as a valid image."); + } + + var uploadRoot = Path.Combine(environment.WebRootPath, "uploads", "books", book.BookID.ToString()); + Directory.CreateDirectory(uploadRoot); + + var version = Guid.NewGuid().ToString("N"); + var thumbnailTemp = Path.Combine(uploadRoot, $"cover-thumb-{version}.tmp"); + var standardTemp = Path.Combine(uploadRoot, $"cover-standard-{version}.tmp"); + var thumbnailPath = Path.Combine(uploadRoot, ThumbnailFileName); + var standardPath = Path.Combine(uploadRoot, StandardFileName); + var thumbnailStoragePath = $"/uploads/books/{book.BookID}/{ThumbnailFileName}"; + var standardStoragePath = $"/uploads/books/{book.BookID}/{StandardFileName}"; + + try + { + SaveWebpVariant(bitmap, thumbnailTemp, 400, 600, 72); + SaveWebpVariant(bitmap, standardTemp, 600, 900, 78); + + ReplaceFile(thumbnailTemp, thumbnailPath); + ReplaceFile(standardTemp, standardPath); + + var oldThumbnailPath = book.CoverThumbnailPath; + var oldStandardPath = book.CoverStandardPath; + await books.UpdateCoverAsync(book.BookID, thumbnailStoragePath, standardStoragePath); + await MarkPreviousLedgerRowsAsync(oldThumbnailPath); + await MarkPreviousLedgerRowsAsync(oldStandardPath); + await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, thumbnailStoragePath, "Book cover thumbnail", new FileInfo(thumbnailPath).Length); + await TrackCoverAsync(ownerUserId.Value, book.ProjectID, book.BookID, standardStoragePath, "Book cover standard", new FileInfo(standardPath).Length); + DeletePreviousPhysicalFile(oldThumbnailPath, thumbnailStoragePath); + DeletePreviousPhysicalFile(oldStandardPath, standardStoragePath); + return new(true); + } + catch (Exception ex) + { + TryDeleteFile(thumbnailTemp); + TryDeleteFile(standardTemp); + logger.LogWarning(ex, "Book cover upload failed for book {BookID}.", book.BookID); + return new(false, "Cover image could not be processed. Try another image."); + } + } + } + + public async Task DeleteCoverFilesAsync(Book book) + { + await MarkAndDeletePreviousAsync(book.CoverThumbnailPath, null); + await MarkAndDeletePreviousAsync(book.CoverStandardPath, null); + } + + private static void SaveWebpVariant(SKBitmap source, string path, int width, int height, int quality) + { + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + var canvas = surface.Canvas; + canvas.Clear(SKColors.Transparent); + + var sourceRatio = source.Width / (float)source.Height; + var targetRatio = width / (float)height; + SKRect sourceRect; + + if (sourceRatio > targetRatio) + { + var cropWidth = source.Height * targetRatio; + var left = (source.Width - cropWidth) / 2f; + sourceRect = new SKRect(left, 0, left + cropWidth, source.Height); + } + else + { + var cropHeight = source.Width / targetRatio; + var top = (source.Height - cropHeight) / 2f; + sourceRect = new SKRect(0, top, source.Width, top + cropHeight); + } + + using var paint = new SKPaint { IsAntialias = true }; + using var sourceImage = SKImage.FromBitmap(source); + canvas.DrawImage(sourceImage, sourceRect, new SKRect(0, 0, width, height), new SKSamplingOptions(SKCubicResampler.Mitchell), paint); + canvas.Flush(); + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Webp, quality); + using var output = File.Create(path); + data.SaveTo(output); + } + + private async Task TrackCoverAsync(int ownerUserId, int projectId, int bookId, string storagePath, string originalFileName, long fileSizeBytes) + { + await userFiles.CreateAsync(new UserFile + { + UserID = ownerUserId, + ProjectID = projectId, + EntityType = "BookCover", + EntityID = bookId, + FileName = Path.GetFileName(storagePath), + OriginalFileName = originalFileName, + ContentType = "image/webp", + FileSizeBytes = fileSizeBytes, + StoragePath = storagePath + }); + } + + private async Task MarkPreviousLedgerRowsAsync(string? oldStoragePath) + { + if (string.IsNullOrWhiteSpace(oldStoragePath) + || string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await userFiles.MarkDeletedByStoragePathAsync(oldStoragePath); + } + + private async Task MarkAndDeletePreviousAsync(string? oldStoragePath, string? currentStoragePath) + { + await MarkPreviousLedgerRowsAsync(oldStoragePath); + DeletePreviousPhysicalFile(oldStoragePath, currentStoragePath); + } + + private void DeletePreviousPhysicalFile(string? oldStoragePath, string? currentStoragePath) + { + if (string.IsNullOrWhiteSpace(oldStoragePath) + || string.Equals(oldStoragePath, currentStoragePath, StringComparison.OrdinalIgnoreCase) + || string.Equals(oldStoragePath, PlaceholderPath, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + DeleteUploadFile(oldStoragePath); + } + + private void DeleteUploadFile(string storagePath) + { + var absolutePath = ResolveUploadPath(storagePath); + if (absolutePath is null) + { + return; + } + + TryDeleteFile(absolutePath); + } + + private string? ResolveUploadPath(string storagePath) + { + var normalised = storagePath.Replace('\\', '/').TrimStart('/'); + if (!normalised.StartsWith("uploads/books/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); + var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar))); + return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null; + } + + private static void ReplaceFile(string tempPath, string finalPath) + { + if (File.Exists(finalPath)) + { + File.Delete(finalPath); + } + + File.Move(tempPath, finalPath); + } + + private static void TryDeleteFile(string path) + { + if (!File.Exists(path)) + { + return; + } + + File.Delete(path); + } + + private static long EstimatedGeneratedSize(long uploadBytes) + => Math.Max(1, Math.Min(uploadBytes, MaxUploadBytes)); +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index d638925..e740727 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -84,10 +84,12 @@ public interface IBookService Task GetCreateAsync(int projectId); Task GetEditAsync(int bookId); Task PopulateEditContextAsync(BookEditViewModel model); - Task SaveAsync(BookEditViewModel model); + Task SaveAsync(BookEditViewModel model); Task ArchiveAsync(int bookId); } +public sealed record BookSaveResult(int BookID, bool Succeeded, string? ErrorMessage = null); + public interface IChapterService { Task GetDetailAsync(int chapterId); @@ -761,6 +763,7 @@ public sealed class BookService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, + IBookCoverService bookCovers, ISubscriptionService subscriptions, IProjectActivityService activity, ICurrentUserService currentUser) : IBookService @@ -802,6 +805,7 @@ public sealed class BookService( Project = project, BookNumber = existing.Count + 1, Status = BookStatuses.Planning, + CoverThumbnailPath = BookCoverService.PlaceholderPath, StatusOptions = BuildStatusOptions(BookStatuses.Planning) }; } @@ -829,6 +833,8 @@ public sealed class BookService( WritingStartDate = book.WritingStartDate, TargetCompletionDate = book.TargetCompletionDate, PublicationDate = book.PublicationDate, + CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath, + CoverStandardPath = book.CoverStandardPath, Project = await projects.GetAsync(book.ProjectID), StatusOptions = BuildStatusOptions(book.Status) }; @@ -838,9 +844,15 @@ public sealed class BookService( { model.Project = await projects.GetAsync(model.ProjectID); model.StatusOptions = BuildStatusOptions(model.Status); + if (model.BookID != 0) + { + var book = await books.GetAsync(model.BookID); + model.CoverThumbnailPath = book?.CoverThumbnailPath ?? BookCoverService.PlaceholderPath; + model.CoverStandardPath = book?.CoverStandardPath; + } } - public async Task SaveAsync(BookEditViewModel model) + public async Task SaveAsync(BookEditViewModel model) { if (model.BookID == 0) { @@ -870,7 +882,23 @@ public sealed class BookService( PublicationDate = model.PublicationDate }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); - return bookId; + + if (model.CoverUpload is { Length: > 0 }) + { + var savedBook = await books.GetAsync(bookId); + if (savedBook is null) + { + return new(bookId, false, "Book was saved, but the cover could not be linked."); + } + + var coverResult = await bookCovers.UploadCoverAsync(savedBook, model.CoverUpload); + if (!coverResult.Succeeded) + { + return new(bookId, false, coverResult.ErrorMessage); + } + } + + return new(bookId, true); } private static IReadOnlyList BuildStatusOptions(string? selectedStatus) @@ -6526,7 +6554,12 @@ public sealed class ScenarioService(IProjectRepository projects, IBookRepository public Task ArchiveAsync(int scenarioId) => scenarios.ArchiveAsync(scenarioId); } -public sealed class ArchiveService(IArchiveRepository archives, IProjectActivityService activity, ICurrentUserService currentUser) : IArchiveService +public sealed class ArchiveService( + IArchiveRepository archives, + IBookRepository books, + IBookCoverService bookCovers, + IProjectActivityService activity, + ICurrentUserService currentUser) : IArchiveService { private static readonly (string Value, string Label)[] EntityTypes = [ @@ -6587,12 +6620,20 @@ public sealed class ArchiveService(IArchiveRepository archives, IProjectActivity public async Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) { var item = await FindArchivedItemContextAsync(entityType, entityId); + var bookCoverPaths = string.Equals(entityType, "Book", StringComparison.OrdinalIgnoreCase) + ? await books.GetCoverPathsAsync(entityId) + : null; var result = await archives.DeleteForeverAsync(entityType, entityId, confirmationText); if (result.Succeeded && item?.ProjectID.HasValue == true) { await activity.RecordAsync(item.ProjectID.Value, "Deleted", entityType, entityId, item.DisplayName); } + if (result.Succeeded && bookCoverPaths is not null) + { + await bookCovers.DeleteCoverFilesAsync(bookCoverPaths); + } + return result; } diff --git a/PlotLine/Sql/067_Phase13B_BookCoverUpload.sql b/PlotLine/Sql/067_Phase13B_BookCoverUpload.sql new file mode 100644 index 0000000..d2792f7 --- /dev/null +++ b/PlotLine/Sql/067_Phase13B_BookCoverUpload.sql @@ -0,0 +1,135 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.Books', N'CoverThumbnailPath') IS NULL + ALTER TABLE dbo.Books ADD CoverThumbnailPath nvarchar(1000) NULL; +GO + +IF COL_LENGTH(N'dbo.Books', N'CoverStandardPath') IS NULL + ALTER TABLE dbo.Books ADD CoverStandardPath nvarchar(1000) NULL; +GO + +CREATE OR ALTER PROCEDURE dbo.Book_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + 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 = @ProjectID AND b.IsArchived = 0 + ORDER BY b.SortOrder, b.BookNumber, b.BookTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Book_Get + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + 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.BookID = @BookID AND b.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Book_UpdateCover + @BookID int, + @CoverThumbnailPath nvarchar(1000) = NULL, + @CoverStandardPath nvarchar(1000) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.Books + SET CoverThumbnailPath = @CoverThumbnailPath, + CoverStandardPath = @CoverStandardPath, + UpdatedDate = SYSUTCDATETIME() + WHERE BookID = @BookID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 6ace42c..f5ee55c 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -96,6 +96,12 @@ public sealed class BookEditViewModel [Display(Name = "Publication date")] public DateTime? PublicationDate { get; set; } + [Display(Name = "Cover image")] + public IFormFile? CoverUpload { get; set; } + + public string CoverThumbnailPath { get; set; } = "/images/placeholders/book-cover.svg"; + public string? CoverStandardPath { get; set; } + public string? Description { get; set; } public Project? Project { get; set; } public IReadOnlyList StatusOptions { get; set; } = []; diff --git a/PlotLine/Views/Books/Edit.cshtml b/PlotLine/Views/Books/Edit.cshtml index 9a0010b..f629959 100644 --- a/PlotLine/Views/Books/Edit.cshtml +++ b/PlotLine/Views/Books/Edit.cshtml @@ -11,7 +11,7 @@ Back to project -
+
@@ -25,6 +25,19 @@ +
+ +
+ Current book cover +
+ +
JPG, PNG or WebP. Maximum 10 MB.
+ +
+
+ + +
@@ -60,10 +73,6 @@
-
- - -
diff --git a/PlotLine/wwwroot/images/placeholders/book-cover.svg b/PlotLine/wwwroot/images/placeholders/book-cover.svg new file mode 100644 index 0000000..b061c02 --- /dev/null +++ b/PlotLine/wwwroot/images/placeholders/book-cover.svg @@ -0,0 +1,10 @@ + + Book cover placeholder + A simple placeholder for books without uploaded covers. + + + + + + + diff --git a/PlotLine/wwwroot/uploads/books/17/cover-standard.webp b/PlotLine/wwwroot/uploads/books/17/cover-standard.webp new file mode 100644 index 0000000..618bd49 Binary files /dev/null and b/PlotLine/wwwroot/uploads/books/17/cover-standard.webp differ diff --git a/PlotLine/wwwroot/uploads/books/17/cover-thumb.webp b/PlotLine/wwwroot/uploads/books/17/cover-thumb.webp new file mode 100644 index 0000000..9b059d2 Binary files /dev/null and b/PlotLine/wwwroot/uploads/books/17/cover-thumb.webp differ