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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user