Phase 13B: Book Cover Upload and Processing.
This commit is contained in:
parent
4e71010814
commit
8a4b016526
@ -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]
|
||||
|
||||
@ -77,6 +77,7 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact
|
||||
var bookMetadata = (await connection.QueryAsync<Book>(
|
||||
"""
|
||||
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
|
||||
|
||||
@ -49,7 +49,9 @@ public interface IBookRepository
|
||||
{
|
||||
Task<IReadOnlyList<Book>> ListByProjectAsync(int projectId);
|
||||
Task<Book?> GetAsync(int bookId);
|
||||
Task<Book?> GetCoverPathsAsync(int bookId);
|
||||
Task<int> 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<Book>("dbo.Book_Get", new { BookID = bookId }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<Book?> GetCoverPathsAsync(int bookId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<Book>(
|
||||
"""
|
||||
SELECT BookID, ProjectID, CoverThumbnailPath, CoverStandardPath
|
||||
FROM dbo.Books
|
||||
WHERE BookID = @BookID;
|
||||
""",
|
||||
new { BookID = bookId });
|
||||
}
|
||||
|
||||
public async Task<int> 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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
<PackageReference Include="Dapper" Version="2.1.79" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.4" />
|
||||
<PackageReference Include="Stripe.net" Version="52.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -115,6 +115,7 @@ public class Program
|
||||
builder.Services.AddScoped<IHardDeleteFileCleanupService, HardDeleteFileCleanupService>();
|
||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||
builder.Services.AddScoped<IBookCoverService, BookCoverService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||
|
||||
274
PlotLine/Services/BookCoverService.cs
Normal file
274
PlotLine/Services/BookCoverService.cs
Normal file
@ -0,0 +1,274 @@
|
||||
using SkiaSharp;
|
||||
using System.Collections.Concurrent;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IBookCoverService
|
||||
{
|
||||
Task<BookCoverUploadResult> 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<BookCoverService> 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<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".webp" };
|
||||
private static readonly ConcurrentDictionary<int, SemaphoreSlim> BookLocks = new();
|
||||
|
||||
public async Task<BookCoverUploadResult> 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<BookCoverUploadResult> 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));
|
||||
}
|
||||
@ -84,10 +84,12 @@ public interface IBookService
|
||||
Task<BookEditViewModel?> GetCreateAsync(int projectId);
|
||||
Task<BookEditViewModel?> GetEditAsync(int bookId);
|
||||
Task PopulateEditContextAsync(BookEditViewModel model);
|
||||
Task<int> SaveAsync(BookEditViewModel model);
|
||||
Task<BookSaveResult> SaveAsync(BookEditViewModel model);
|
||||
Task ArchiveAsync(int bookId);
|
||||
}
|
||||
|
||||
public sealed record BookSaveResult(int BookID, bool Succeeded, string? ErrorMessage = null);
|
||||
|
||||
public interface IChapterService
|
||||
{
|
||||
Task<ChapterDetailViewModel?> 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<int> SaveAsync(BookEditViewModel model)
|
||||
public async Task<BookSaveResult> 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<SelectListItem> 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<ArchiveDeleteResult> 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;
|
||||
}
|
||||
|
||||
|
||||
135
PlotLine/Sql/067_Phase13B_BookCoverUpload.sql
Normal file
135
PlotLine/Sql/067_Phase13B_BookCoverUpload.sql
Normal file
@ -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
|
||||
@ -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<SelectListItem> StatusOptions { get; set; } = [];
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Back to project</a>
|
||||
</div>
|
||||
|
||||
<form asp-action="Save" method="post" class="edit-panel">
|
||||
<form asp-action="Save" method="post" enctype="multipart/form-data" class="edit-panel">
|
||||
<input asp-for="BookID" type="hidden" />
|
||||
<input asp-for="ProjectID" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
@ -25,6 +25,19 @@
|
||||
<input asp-for="BookTitle" class="form-control" />
|
||||
<span asp-validation-for="BookTitle" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="CoverUpload" class="form-label"></label>
|
||||
<div class="mb-2">
|
||||
<img src="@Model.CoverThumbnailPath" alt="Current book cover" class="img-fluid border" style="max-width:160px;" />
|
||||
</div>
|
||||
<input asp-for="CoverUpload" class="form-control" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" />
|
||||
<div class="form-text">JPG, PNG or WebP. Maximum 10 MB.</div>
|
||||
<span asp-validation-for="CoverUpload" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label asp-for="ShortDescription" class="form-label"></label>
|
||||
<textarea asp-for="ShortDescription" class="form-control" rows="6"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Subtitle" class="form-label"></label>
|
||||
<input asp-for="Subtitle" class="form-control" />
|
||||
@ -60,10 +73,6 @@
|
||||
<input asp-for="PublicationDate" class="form-control" />
|
||||
<span asp-validation-for="PublicationDate" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="ShortDescription" class="form-label"></label>
|
||||
<textarea asp-for="ShortDescription" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="Description" class="form-label"></label>
|
||||
<textarea asp-for="Description" class="form-control" rows="4"></textarea>
|
||||
|
||||
10
PlotLine/wwwroot/images/placeholders/book-cover.svg
Normal file
10
PlotLine/wwwroot/images/placeholders/book-cover.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="600" viewBox="0 0 400 600" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Book cover placeholder</title>
|
||||
<desc id="desc">A simple placeholder for books without uploaded covers.</desc>
|
||||
<rect width="400" height="600" fill="#f4f1eb"/>
|
||||
<rect x="34" y="34" width="332" height="532" rx="8" fill="#ffffff" stroke="#c9c1b4" stroke-width="4"/>
|
||||
<rect x="70" y="92" width="260" height="26" fill="#b7ab9c"/>
|
||||
<rect x="100" y="136" width="200" height="14" fill="#d6cec4"/>
|
||||
<rect x="104" y="420" width="192" height="120" rx="4" fill="#ebe4dc" stroke="#d6cec4" stroke-width="2"/>
|
||||
<path d="M150 476h100M170 454h60M170 498h60" stroke="#a79b8f" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
BIN
PlotLine/wwwroot/uploads/books/17/cover-standard.webp
Normal file
BIN
PlotLine/wwwroot/uploads/books/17/cover-standard.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
PlotLine/wwwroot/uploads/books/17/cover-thumb.webp
Normal file
BIN
PlotLine/wwwroot/uploads/books/17/cover-thumb.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Loading…
x
Reference in New Issue
Block a user