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:
@@ -34,6 +34,7 @@ namespace PowderCoating.Web.Controllers
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IMeasurementConversionService _measurementService;
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ICatalogImageService _catalogImageService;
|
||||
|
||||
public CatalogItemsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -43,7 +44,8 @@ namespace PowderCoating.Web.Controllers
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITenantContext tenantContext,
|
||||
IMeasurementConversionService measurementService,
|
||||
ISubscriptionService subscriptionService)
|
||||
ISubscriptionService subscriptionService,
|
||||
ICatalogImageService catalogImageService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -53,6 +55,7 @@ namespace PowderCoating.Web.Controllers
|
||||
_tenantContext = tenantContext;
|
||||
_measurementService = measurementService;
|
||||
_subscriptionService = subscriptionService;
|
||||
_catalogImageService = catalogImageService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -215,7 +218,7 @@ namespace PowderCoating.Web.Controllers
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreateCatalogItemDto dto)
|
||||
public async Task<IActionResult> Create(CreateCatalogItemDto dto, IFormFile? image)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -241,6 +244,22 @@ namespace PowderCoating.Web.Controllers
|
||||
await _unitOfWork.CatalogItems.AddAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Upload image after save so we have a stable item ID for the blob path.
|
||||
if (image != null && image.Length > 0)
|
||||
{
|
||||
var imgResult = await _catalogImageService.UploadAsync(image, item.Id, companyId, null, null);
|
||||
if (imgResult.Success)
|
||||
{
|
||||
item.ImagePath = imgResult.ImagePath;
|
||||
item.ThumbnailPath = imgResult.ThumbnailPath;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Catalog item '{item.Name}' created successfully.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
@@ -257,7 +276,6 @@ namespace PowderCoating.Web.Controllers
|
||||
await PopulateCategoryDropdown();
|
||||
await PopulateAccountDropdowns();
|
||||
|
||||
// Set measurement unit labels for view repopulation
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
@@ -288,6 +306,10 @@ namespace PowderCoating.Web.Controllers
|
||||
_logger.LogDebug("Mapping item {ItemId} to DTO", id);
|
||||
var dto = _mapper.Map<UpdateCatalogItemDto>(item);
|
||||
|
||||
ViewBag.CurrentImagePath = item.ImagePath;
|
||||
ViewBag.CurrentThumbnailPath = item.ThumbnailPath;
|
||||
ViewBag.HasImage = !string.IsNullOrEmpty(item.ImagePath);
|
||||
|
||||
_logger.LogDebug("Populating category dropdown for item {ItemId}", id);
|
||||
await PopulateCategoryDropdown();
|
||||
await PopulateAccountDropdowns();
|
||||
@@ -317,7 +339,7 @@ namespace PowderCoating.Web.Controllers
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto)
|
||||
public async Task<IActionResult> Edit(int id, UpdateCatalogItemDto dto, IFormFile? image, bool removeImage = false)
|
||||
{
|
||||
if (id != dto.Id)
|
||||
{
|
||||
@@ -337,6 +359,29 @@ namespace PowderCoating.Web.Controllers
|
||||
}
|
||||
|
||||
_mapper.Map(dto, item);
|
||||
|
||||
if (image != null && image.Length > 0)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var imgResult = await _catalogImageService.UploadAsync(
|
||||
image, item.Id, companyId, item.ImagePath, item.ThumbnailPath);
|
||||
if (imgResult.Success)
|
||||
{
|
||||
item.ImagePath = imgResult.ImagePath;
|
||||
item.ThumbnailPath = imgResult.ThumbnailPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Warning"] = $"Item saved but image upload failed: {imgResult.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
else if (removeImage)
|
||||
{
|
||||
await _catalogImageService.DeleteAsync(item.ImagePath, item.ThumbnailPath);
|
||||
item.ImagePath = null;
|
||||
item.ThumbnailPath = null;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Catalog item '{item.Name}' updated successfully.";
|
||||
@@ -346,7 +391,6 @@ namespace PowderCoating.Web.Controllers
|
||||
await PopulateCategoryDropdown();
|
||||
await PopulateAccountDropdowns();
|
||||
|
||||
// Set measurement unit labels for view repopulation
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
@@ -359,7 +403,6 @@ namespace PowderCoating.Web.Controllers
|
||||
await PopulateCategoryDropdown();
|
||||
await PopulateAccountDropdowns();
|
||||
|
||||
// Set measurement unit labels for view repopulation
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
@@ -452,7 +495,8 @@ namespace PowderCoating.Web.Controllers
|
||||
i.DefaultRequiresSandblasting,
|
||||
i.DefaultRequiresMasking,
|
||||
i.DefaultEstimatedMinutes,
|
||||
i.ApproximateArea
|
||||
i.ApproximateArea,
|
||||
thumbnailPath = i.ThumbnailPath
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -541,7 +585,8 @@ namespace PowderCoating.Web.Controllers
|
||||
i.DefaultRequiresSandblasting,
|
||||
i.DefaultRequiresMasking,
|
||||
i.DefaultEstimatedMinutes,
|
||||
i.ApproximateArea
|
||||
i.ApproximateArea,
|
||||
thumbnailPath = i.ThumbnailPath
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -581,7 +626,8 @@ namespace PowderCoating.Web.Controllers
|
||||
requiresMasking = item.DefaultRequiresMasking,
|
||||
estimatedMinutes = item.DefaultEstimatedMinutes ?? 0,
|
||||
approximateArea = item.ApproximateArea ?? 0,
|
||||
categoryName = item.Category.Name
|
||||
categoryName = item.Category.Name,
|
||||
thumbnailPath = item.ThumbnailPath
|
||||
};
|
||||
|
||||
return Json(itemData);
|
||||
@@ -777,6 +823,39 @@ namespace PowderCoating.Web.Controllers
|
||||
|
||||
return result;
|
||||
}
|
||||
/// <summary>
|
||||
/// Serves a catalog item image (full-size or thumbnail) from Azure Blob Storage.
|
||||
/// Uses plain [Authorize] (not the class-level CanManageProducts policy) so that any
|
||||
/// authenticated user — including those who can only create quotes or jobs — can load
|
||||
/// thumbnails rendered in the item wizard.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Image(int id, bool thumbnail = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = await _unitOfWork.CatalogItems.GetByIdAsync(id);
|
||||
if (item == null)
|
||||
return NotFound();
|
||||
|
||||
var blobPath = thumbnail ? item.ThumbnailPath : item.ImagePath;
|
||||
if (string.IsNullOrEmpty(blobPath))
|
||||
return NotFound();
|
||||
|
||||
var (success, content, contentType, error) = await _catalogImageService.DownloadAsync(blobPath);
|
||||
if (!success)
|
||||
return NotFound();
|
||||
|
||||
return File(content, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error serving catalog image for item {ItemId}", id);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates and streams a PDF of all active catalog items, grouped by category, including the
|
||||
/// company's logo and branding. Only active items are included so the PDF serves as a
|
||||
|
||||
Reference in New Issue
Block a user