Phase 13B: Book Cover Upload and Processing.

This commit is contained in:
Nick Beckley 2026-06-15 15:17:31 +01:00
parent 4e71010814
commit 8a4b016526
14 changed files with 526 additions and 12 deletions

View File

@ -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]

View File

@ -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

View File

@ -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();

View File

@ -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; }

View File

@ -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>

View File

@ -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>();

View 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));
}

View File

@ -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;
}

View 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

View File

@ -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; } = [];

View File

@ -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>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB