00bf8a4cd0
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>
140 lines
5.8 KiB
C#
140 lines
5.8 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|