Files
PowderCoatingLogix/src/PowderCoating.Application/Services/CatalogImageService.cs
T
spouliot 00bf8a4cd0 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>
2026-04-25 09:33:59 -04:00

140 lines
5.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}
}