Add catalog item images with thumbnail preview in wizard

Each catalog item now supports one optional image (jpg/jpeg/png/gif/webp,
max 10 MB). Uploading generates a 200x200 JPEG thumbnail automatically via
SixLabors.ImageSharp. Images are stored in Azure Blob Storage under a new
catalogimages container, keyed by {companyId}/catalog/{itemId}/.

- CatalogItem entity: ImagePath + ThumbnailPath (nullable string fields)
- Migration: AddCatalogItemImages applied
- ICatalogImageService / CatalogImageService: upload, thumbnail generation,
  delete; old blobs replaced atomically on re-upload
- CatalogItemsController: Create/Edit accept optional IFormFile image;
  Image(id, thumbnail) action serves blobs with [Authorize] so wizard users
  can load thumbnails without CanManageProducts policy
- Catalog index (_CategoryNode): 40x40 thumbnail (or placeholder icon)
  left of each item name
- Details view: image card in right column with click-to-full-size link
- Create/Edit views: file picker with live preview; Edit shows current
  thumbnail with Remove checkbox
- Wizard (item-wizard.js): thumbnails in product list with hover preview
  that follows the cursor (showCatalogPreview / moveCatalogPreview);
  fixed Bootstrap d-flex !important bug that broke the filter box by
  moving flex layout to an inner wrapper div

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 09:33:59 -04:00
parent 3327c86909
commit 00bf8a4cd0
23 changed files with 9766 additions and 27 deletions
@@ -15,4 +15,5 @@ public class StorageContainers
public string ReceiptImages { get; set; } = "receiptimages";
public string QuoteImages { get; set; } = "quoteimages";
public string BugReportMedia { get; set; } = "bugreportmedia";
public string CatalogImages { get; set; } = "catalogimages";
}
@@ -29,6 +29,9 @@ namespace PowderCoating.Application.DTOs.Catalog
[Display(Name = "COGS Account")]
public int? CogsAccountId { get; set; }
public string? CogsAccountName { get; set; }
public string? ImagePath { get; set; }
public string? ThumbnailPath { get; set; }
}
/// <summary>
@@ -43,6 +46,7 @@ namespace PowderCoating.Application.DTOs.Catalog
public string CategoryName { get; set; } = string.Empty;
public decimal DefaultPrice { get; set; }
public bool IsActive { get; set; }
public string? ThumbnailPath { get; set; }
}
/// <summary>
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Handles upload, thumbnail generation, and deletion of catalog item images stored in Azure Blob Storage.
/// All blobs are scoped under {companyId}/catalog/{itemId}/ so tenants never share a path.
/// </summary>
public interface ICatalogImageService
{
/// <summary>
/// Uploads the image, generates a 200×200 JPEG thumbnail, stores both blobs, and returns their paths.
/// On success the caller should persist the returned paths to <c>CatalogItem.ImagePath</c> and
/// <c>CatalogItem.ThumbnailPath</c>. Any previously stored blobs for the same item are deleted first.
/// </summary>
Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath);
/// <summary>
/// Downloads a catalog image blob and returns its raw bytes and content-type for streaming to the browser.
/// </summary>
Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath);
/// <summary>
/// Deletes both the full-size image and thumbnail blobs. Safe to call with null paths.
/// </summary>
Task DeleteAsync(string? imagePath, string? thumbnailPath);
}
@@ -92,7 +92,10 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
// Image paths are set by CatalogImageService after the entity is saved, not from the DTO.
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// UpdateCatalogItemDto -> CatalogItem
CreateMap<UpdateCatalogItemDto, CatalogItem>()
@@ -104,7 +107,9 @@ namespace PowderCoating.Application.Mappings
.ForMember(dest => dest.IsDeleted, opt => opt.Ignore())
.ForMember(dest => dest.Category, opt => opt.Ignore())
.ForMember(dest => dest.RevenueAccount, opt => opt.Ignore())
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore());
.ForMember(dest => dest.CogsAccount, opt => opt.Ignore())
.ForMember(dest => dest.ImagePath, opt => opt.Ignore())
.ForMember(dest => dest.ThumbnailPath, opt => opt.Ignore());
// CatalogItem -> UpdateCatalogItemDto (reverse mapping for Edit)
CreateMap<CatalogItem, UpdateCatalogItemDto>();
@@ -18,6 +18,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!-- Force newer versions of transitive packages with known CVEs -->
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace PowderCoating.Application.Services;
/// <summary>
/// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a
/// 200×200 JPEG thumbnail. Both blobs are stored under <c>{companyId}/catalog/{itemId}/</c> so that
/// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically
/// (delete-then-upload) to avoid orphaned files accumulating over time.
/// </summary>
public class CatalogImageService : ICatalogImageService
{
private readonly IAzureBlobStorageService _blobService;
private readonly StorageSettings _settings;
private readonly ILogger<CatalogImageService> _logger;
private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB
private const int ThumbnailSize = 200;
public CatalogImageService(
IAzureBlobStorageService blobService,
IOptions<StorageSettings> settings,
ILogger<CatalogImageService> logger)
{
_blobService = blobService;
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG
/// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as
/// JPEG regardless of the source format for predictable browser rendering and smaller file sizes.
/// </summary>
public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync(
IFormFile file,
int itemId,
int companyId,
string? existingImagePath,
string? existingThumbnailPath)
{
if (file == null || file.Length == 0)
return (false, string.Empty, string.Empty, "No file provided.");
if (file.Length > MaxFileSizeBytes)
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
var container = _settings.Containers.CatalogImages;
var blobId = Guid.NewGuid().ToString("N");
var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}";
var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg";
// Delete existing blobs before uploading replacements.
await DeleteAsync(existingImagePath, existingThumbnailPath);
// Upload original.
using var originalStream = file.OpenReadStream();
var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType);
if (!uploadResult.Success)
return (false, string.Empty, string.Empty, uploadResult.ErrorMessage);
// Generate and upload thumbnail.
using var thumbStream = await GenerateThumbnailAsync(file);
if (thumbStream == null)
{
// Thumbnail generation failed; clean up the original and bail out.
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, "Failed to generate thumbnail.");
}
var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg");
if (!thumbResult.Success)
{
await _blobService.DeleteAsync(container, imagePath);
return (false, string.Empty, string.Empty, thumbResult.ErrorMessage);
}
_logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath);
return (true, imagePath, thumbPath, string.Empty);
}
/// <inheritdoc/>
public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath)
{
return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath);
}
/// <inheritdoc/>
public async Task DeleteAsync(string? imagePath, string? thumbnailPath)
{
var container = _settings.Containers.CatalogImages;
if (!string.IsNullOrEmpty(imagePath))
await _blobService.DeleteAsync(container, imagePath);
if (!string.IsNullOrEmpty(thumbnailPath))
await _blobService.DeleteAsync(container, thumbnailPath);
}
/// <summary>
/// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while
/// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the
/// caller can surface a clean error without propagating an ImageSharp exception.
/// </summary>
private async Task<MemoryStream?> GenerateThumbnailAsync(IFormFile file)
{
try
{
using var inputStream = file.OpenReadStream();
using var image = await Image.LoadAsync(inputStream);
image.Mutate(ctx => ctx.Resize(new ResizeOptions
{
Size = new Size(ThumbnailSize, ThumbnailSize),
Mode = ResizeMode.Max
}));
var ms = new MemoryStream();
await image.SaveAsync(ms, new JpegEncoder { Quality = 85 });
ms.Position = 0;
return ms;
}
catch (Exception ex)
{
_logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName);
return null;
}
}
}