From c824e9da1c4a8cc8ce4c3f26676c8542fba07378 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Sun, 17 Aug 2025 15:45:59 -0300 Subject: [PATCH] feat: imagens!!!! Agora tenho uma imagem no topo! --- .../BCards.IntegrationTests.csproj | 2 +- src/BCards.Web/BCards.Web.csproj | 2 +- src/BCards.Web/Controllers/AdminController.cs | 56 ++++- src/BCards.Web/Controllers/ImageController.cs | 202 ++++++++++++++++++ src/BCards.Web/Models/IPageDisplay.cs | 5 +- src/BCards.Web/Models/LivePage.cs | 16 +- src/BCards.Web/Models/UserPage.cs | 16 +- src/BCards.Web/Program.cs | 13 ++ src/BCards.Web/Services/GridFSImageStorage.cs | 199 +++++++++++++++++ .../Services/IImageStorageService.cs | 34 +++ src/BCards.Web/Services/LivePageService.cs | 2 +- src/BCards.Web/Services/SeoService.cs | 2 +- .../ViewModels/ManagePageViewModel.cs | 11 + src/BCards.Web/Views/Admin/ManagePage.cshtml | 135 +++++++++++- src/BCards.Web/Views/Home/Index.cshtml | 6 +- .../Views/Shared/_ThemeStyles.cshtml | 14 +- src/BCards.Web/Views/UserPage/Display.cshtml | 34 +-- src/BCards.Web/wwwroot/css/userpage.css | 25 +++ .../wwwroot/images/default-avatar.svg | 11 + 19 files changed, 745 insertions(+), 40 deletions(-) create mode 100644 src/BCards.Web/Controllers/ImageController.cs create mode 100644 src/BCards.Web/Services/GridFSImageStorage.cs create mode 100644 src/BCards.Web/Services/IImageStorageService.cs create mode 100644 src/BCards.Web/wwwroot/images/default-avatar.svg diff --git a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj index 9421555..f8abaeb 100644 --- a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj +++ b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj index c04f18e..e123894 100644 --- a/src/BCards.Web/BCards.Web.csproj +++ b/src/BCards.Web/BCards.Web.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index 82d0930..319fb67 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -20,6 +20,7 @@ public class AdminController : Controller private readonly IModerationService _moderationService; private readonly IEmailService _emailService; private readonly ILivePageService _livePageService; + private readonly IImageStorageService _imageStorage; private readonly ILogger _logger; public AdminController( @@ -30,6 +31,7 @@ public class AdminController : Controller IModerationService moderationService, IEmailService emailService, ILivePageService livePageService, + IImageStorageService imageStorage, ILogger logger) { _authService = authService; @@ -39,6 +41,7 @@ public class AdminController : Controller _moderationService = moderationService; _emailService = emailService; _livePageService = livePageService; + _imageStorage = imageStorage; _logger = logger; } @@ -66,13 +69,13 @@ public class AdminController : Controller var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id); if (livePage != null) { - listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0 , TotalClicks = livePage.Analytics?.TotalClicks ?? 0 }); - } - else - { - listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) }); + listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 }); } } + else + { + listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) }); + } } var dashboardModel = new DashboardViewModel @@ -189,6 +192,36 @@ public class AdminController : Controller ModelState.Remove(x => x.TwitterUrl); ModelState.Remove(x => x.WhatsAppNumber); + // Processar upload de imagem se fornecida + if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0) + { + try + { + using var memoryStream = new MemoryStream(); + await model.ProfileImageFile.CopyToAsync(memoryStream); + var imageBytes = memoryStream.ToArray(); + + var imageId = await _imageStorage.SaveImageAsync( + imageBytes, + model.ProfileImageFile.FileName, + model.ProfileImageFile.ContentType + ); + + model.ProfileImageId = imageId; + _logger.LogInformation("Profile image uploaded successfully: {ImageId}", imageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading profile image"); + ModelState.AddModelError("ProfileImageFile", "Erro ao fazer upload da imagem. Tente novamente."); + + // Repopulate dropdowns + model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); + model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); + return View(model); + } + } + if (!ModelState.IsValid) { _logger.LogWarning("ModelState is invalid:"); @@ -196,8 +229,10 @@ public class AdminController : Controller { _logger.LogWarning($"Key: {error.Key}, Errors: {string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage))}"); } - + // Repopulate dropdowns + var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName); + model.Slug = slug; model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); return View(model); @@ -276,6 +311,12 @@ public class AdminController : Controller return RedirectToAction("Dashboard"); } + // IMPORTANTE: Preservar ProfileImageId da página existente se não houver novo upload + if (model.ProfileImageFile == null || model.ProfileImageFile.Length == 0) + { + model.ProfileImageId = existingPage.ProfileImageId; + } + UpdateUserPageFromModel(existingPage, model); // Set status to PendingModeration for updates @@ -581,6 +622,7 @@ public class AdminController : Controller Bio = page.Bio, Slug = page.Slug, SelectedTheme = page.Theme?.Name ?? "minimalist", + ProfileImageId = page.ProfileImageId, Links = page.Links?.Select((l, index) => new ManageLinkViewModel { Id = $"link_{index}", @@ -617,6 +659,7 @@ public class AdminController : Controller Slug = SlugHelper.CreateSlug(model.Slug.ToLower()), Theme = theme, Status = ViewModels.PageStatus.Active, + ProfileImageId = model.ProfileImageId, Links = new List() }; @@ -704,6 +747,7 @@ public class AdminController : Controller page.BusinessType = model.BusinessType; page.Bio = model.Bio; page.Slug = model.Slug; + page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId page.UpdatedAt = DateTime.UtcNow; // Update links diff --git a/src/BCards.Web/Controllers/ImageController.cs b/src/BCards.Web/Controllers/ImageController.cs new file mode 100644 index 0000000..eab5ac1 --- /dev/null +++ b/src/BCards.Web/Controllers/ImageController.cs @@ -0,0 +1,202 @@ +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class ImageController : ControllerBase +{ + private readonly IImageStorageService _imageStorage; + private readonly ILogger _logger; + + public ImageController(IImageStorageService imageStorage, ILogger logger) + { + _imageStorage = imageStorage; + _logger = logger; + } + + [HttpGet("{imageId}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })] + public async Task GetImage(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + { + _logger.LogWarning("Image request with empty ID"); + return BadRequest("Image ID is required"); + } + + var imageBytes = await _imageStorage.GetImageAsync(imageId); + + if (imageBytes == null || imageBytes.Length == 0) + { + _logger.LogWarning("Image not found: {ImageId}", imageId); + return NotFound("Image not found"); + } + + // Headers de cache mais agressivos para imagens + Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano + Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R"); + Response.Headers["ETag"] = $"\"{imageId}\""; + + return File(imageBytes, "image/jpeg", enableRangeProcessing: true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); + return NotFound("Image not found"); + } + } + + [HttpPost("upload")] + [RequestSizeLimit(5 * 1024 * 1024)] // 5MB máximo + [DisableRequestSizeLimit] // Para formulários grandes + public async Task UploadImage(IFormFile file) + { + try + { + if (file == null || file.Length == 0) + { + _logger.LogWarning("Upload request with no file"); + return BadRequest(new { error = "No file provided", code = "NO_FILE" }); + } + + // Validações de tipo + var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" }; + if (!allowedTypes.Contains(file.ContentType.ToLower())) + { + _logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType); + return BadRequest(new { + error = "Invalid file type. Only JPEG, PNG and GIF are allowed.", + code = "INVALID_TYPE" + }); + } + + // Validação de tamanho + if (file.Length > 5 * 1024 * 1024) // 5MB + { + _logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024)); + return BadRequest(new { + error = "File too large. Maximum size is 5MB.", + code = "FILE_TOO_LARGE" + }); + } + + // Processar upload + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var imageBytes = memoryStream.ToArray(); + + // Validação adicional: verificar se é realmente uma imagem + if (!IsValidImageBytes(imageBytes)) + { + _logger.LogWarning("Invalid image data uploaded"); + return BadRequest(new { + error = "Invalid image data.", + code = "INVALID_IMAGE" + }); + } + + var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType); + + _logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB", + imageId, file.FileName, file.Length / 1024); + + return Ok(new { + success = true, + imageId, + url = $"/api/image/{imageId}", + originalSize = file.Length, + fileName = file.FileName + }); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid upload parameters"); + return BadRequest(new { + error = ex.Message, + code = "VALIDATION_ERROR" + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName); + return StatusCode(500, new { + error = "Error uploading image. Please try again.", + code = "UPLOAD_ERROR" + }); + } + } + + [HttpDelete("{imageId}")] + public async Task DeleteImage(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return BadRequest(new { error = "Image ID is required" }); + + var deleted = await _imageStorage.DeleteImageAsync(imageId); + + if (!deleted) + return NotFound(new { error = "Image not found" }); + + _logger.LogInformation("Image deleted: {ImageId}", imageId); + return Ok(new { success = true, message = "Image deleted successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); + return StatusCode(500, new { error = "Error deleting image" }); + } + } + + [HttpHead("{imageId}")] + public async Task ImageExists(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return BadRequest(); + + var exists = await _imageStorage.ImageExistsAsync(imageId); + return exists ? Ok() : NotFound(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); + return StatusCode(500); + } + } + + private static bool IsValidImageBytes(byte[] bytes) + { + if (bytes == null || bytes.Length < 4) + return false; + + // Verificar assinaturas de arquivos de imagem + var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF }; + var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; + var gifSignature = new byte[] { 0x47, 0x49, 0x46 }; + + return StartsWithSignature(bytes, jpegSignature) || + StartsWithSignature(bytes, pngSignature) || + StartsWithSignature(bytes, gifSignature); + } + + private static bool StartsWithSignature(byte[] bytes, byte[] signature) + { + if (bytes.Length < signature.Length) + return false; + + for (int i = 0; i < signature.Length; i++) + { + if (bytes[i] != signature[i]) + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Models/IPageDisplay.cs b/src/BCards.Web/Models/IPageDisplay.cs index b85deb8..63da837 100644 --- a/src/BCards.Web/Models/IPageDisplay.cs +++ b/src/BCards.Web/Models/IPageDisplay.cs @@ -12,7 +12,7 @@ string Slug { get; } string DisplayName { get; } string Bio { get; } - string ProfileImage { get; } + string? ProfileImageId { get; } string BusinessType { get; } PageTheme Theme { get; } List Links { get; } @@ -20,7 +20,8 @@ string Language { get; } DateTime CreatedAt { get; } - // Propriedade calculada comum + // Propriedades calculadas comuns string FullUrl { get; } + string ProfileImageUrl { get; } } } diff --git a/src/BCards.Web/Models/LivePage.cs b/src/BCards.Web/Models/LivePage.cs index a6c0eec..3a63e25 100644 --- a/src/BCards.Web/Models/LivePage.cs +++ b/src/BCards.Web/Models/LivePage.cs @@ -29,8 +29,14 @@ public class LivePage : IPageDisplay [BsonElement("bio")] public string Bio { get; set; } = string.Empty; + [BsonElement("profileImageId")] + public string? ProfileImageId { get; set; } + + // Campo antigo - ignorar durante deserialização para compatibilidade [BsonElement("profileImage")] - public string ProfileImage { get; set; } = string.Empty; + [BsonIgnoreIfDefault] + [BsonIgnore] + public string? ProfileImage { get; set; } [BsonElement("businessType")] public string BusinessType { get; set; } = string.Empty; @@ -60,6 +66,14 @@ public class LivePage : IPageDisplay public DateTime CreatedAt { get; set; } public string FullUrl => $"page/{Category}/{Slug}"; + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + [BsonIgnore] + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; } public class LivePageAnalytics diff --git a/src/BCards.Web/Models/UserPage.cs b/src/BCards.Web/Models/UserPage.cs index bb08539..2c4f02c 100644 --- a/src/BCards.Web/Models/UserPage.cs +++ b/src/BCards.Web/Models/UserPage.cs @@ -29,8 +29,14 @@ public class UserPage : IPageDisplay [BsonElement("bio")] public string Bio { get; set; } = string.Empty; + [BsonElement("profileImageId")] + public string? ProfileImageId { get; set; } + + // Campo antigo - ignorar durante deserialização para compatibilidade [BsonElement("profileImage")] - public string ProfileImage { get; set; } = string.Empty; + [BsonIgnoreIfDefault] + [BsonIgnore] + public string? ProfileImage { get; set; } [BsonElement("theme")] public PageTheme Theme { get; set; } = new(); @@ -87,4 +93,12 @@ public class UserPage : IPageDisplay public int PreviewViewCount { get; set; } = 0; public string FullUrl => $"page/{Category}/{Slug}"; + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + [BsonIgnore] + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; } \ No newline at end of file diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 9ef4918..414ab94 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -12,6 +12,7 @@ using Stripe; using Microsoft.AspNetCore.Authentication.OAuth; using SendGrid; using BCards.Web.Middleware; +using Microsoft.AspNetCore.Http.Features; var builder = WebApplication.CreateBuilder(args); @@ -126,6 +127,18 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Image Storage Service +builder.Services.AddScoped(); + +// Configure upload limits for file uploads +builder.Services.Configure(options => +{ + options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10MB for forms with files + options.ValueLengthLimit = int.MaxValue; + options.ValueCountLimit = int.MaxValue; + options.KeyLengthLimit = int.MaxValue; +}); + // 🔥 NOVO: LivePage Services builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/BCards.Web/Services/GridFSImageStorage.cs b/src/BCards.Web/Services/GridFSImageStorage.cs new file mode 100644 index 0000000..cfc35b2 --- /dev/null +++ b/src/BCards.Web/Services/GridFSImageStorage.cs @@ -0,0 +1,199 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Driver.GridFS; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Formats.Jpeg; + +namespace BCards.Web.Services; + +public class GridFSImageStorage : IImageStorageService +{ + private readonly IMongoDatabase _database; + private readonly GridFSBucket _gridFS; + private readonly ILogger _logger; + + private const int TARGET_SIZE = 400; + private const int MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + private static readonly string[] ALLOWED_TYPES = { "image/jpeg", "image/jpg", "image/png", "image/gif" }; + + public GridFSImageStorage(IMongoDatabase database, ILogger logger) + { + _database = database; + _gridFS = new GridFSBucket(database, new GridFSBucketOptions + { + BucketName = "profile_images" + }); + _logger = logger; + } + + public async Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType) + { + try + { + // Validações + if (imageBytes == null || imageBytes.Length == 0) + throw new ArgumentException("Image bytes cannot be null or empty"); + + if (imageBytes.Length > MAX_FILE_SIZE) + throw new ArgumentException($"File size exceeds maximum allowed size of {MAX_FILE_SIZE / (1024 * 1024)}MB"); + + if (!ALLOWED_TYPES.Contains(contentType.ToLower())) + throw new ArgumentException($"Content type {contentType} is not allowed"); + + // Processar e redimensionar imagem + var processedImage = await ProcessImageAsync(imageBytes); + + // Metadata + var options = new GridFSUploadOptions + { + Metadata = new BsonDocument + { + { "originalFileName", fileName }, + { "contentType", "image/jpeg" }, // Sempre JPEG após processamento + { "uploadDate", DateTime.UtcNow }, + { "originalSize", imageBytes.Length }, + { "processedSize", processedImage.Length }, + { "dimensions", $"{TARGET_SIZE}x{TARGET_SIZE}" }, + { "version", "1.0" } + } + }; + + // Nome único para o arquivo + var uniqueFileName = $"profile_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.jpg"; + + // Upload para GridFS + var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, processedImage, options); + + _logger.LogInformation("Image uploaded successfully: {FileId}, Size: {Size}KB", + fileId, processedImage.Length / 1024); + + return fileId.ToString(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading image: {FileName}", fileName); + throw; + } + } + + public async Task GetImageAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return null; + + if (!ObjectId.TryParse(imageId, out var objectId)) + { + _logger.LogWarning("Invalid ObjectId format: {ImageId}", imageId); + return null; + } + + var imageBytes = await _gridFS.DownloadAsBytesAsync(objectId); + return imageBytes; + } + catch (GridFSFileNotFoundException) + { + _logger.LogWarning("Image not found: {ImageId}", imageId); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving image: {ImageId}", imageId); + return null; + } + } + + public async Task DeleteImageAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return false; + + if (!ObjectId.TryParse(imageId, out var objectId)) + return false; + + await _gridFS.DeleteAsync(objectId); + _logger.LogInformation("Image deleted successfully: {ImageId}", imageId); + return true; + } + catch (GridFSFileNotFoundException) + { + _logger.LogWarning("Attempted to delete non-existent image: {ImageId}", imageId); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting image: {ImageId}", imageId); + return false; + } + } + + public async Task ImageExistsAsync(string imageId) + { + try + { + if (string.IsNullOrEmpty(imageId)) + return false; + + if (!ObjectId.TryParse(imageId, out var objectId)) + return false; + + var filter = Builders.Filter.Eq("_id", objectId); + var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync(); + + return fileInfo != null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking image existence: {ImageId}", imageId); + return false; + } + } + + private async Task ProcessImageAsync(byte[] originalBytes) + { + return await Task.Run(() => + { + using var originalImage = Image.Load(originalBytes); + + // Calcular dimensões mantendo aspect ratio + var (newWidth, newHeight) = CalculateResizeDimensions( + originalImage.Width, originalImage.Height, TARGET_SIZE); + + // Criar imagem com fundo branco + using var processedImage = new Image(TARGET_SIZE, TARGET_SIZE); + + // Preencher com fundo branco + processedImage.Mutate(ctx => ctx.BackgroundColor(SixLabors.ImageSharp.Color.White)); + + // Redimensionar a imagem original mantendo aspect ratio + originalImage.Mutate(ctx => ctx.Resize(newWidth, newHeight)); + + // Calcular posição para centralizar a imagem + var x = (TARGET_SIZE - newWidth) / 2; + var y = (TARGET_SIZE - newHeight) / 2; + + // Desenhar a imagem centralizada sobre o fundo branco + processedImage.Mutate(ctx => ctx.DrawImage(originalImage, new Point(x, y), 1f)); + + // Converter para JPEG com compressão otimizada + using var outputStream = new MemoryStream(); + var encoder = new JpegEncoder() + { + Quality = 85 // 85% qualidade + }; + + processedImage.SaveAsJpeg(outputStream, encoder); + return outputStream.ToArray(); + }); + } + + private static (int width, int height) CalculateResizeDimensions(int originalWidth, int originalHeight, int targetSize) + { + var ratio = Math.Min((double)targetSize / originalWidth, (double)targetSize / originalHeight); + return ((int)(originalWidth * ratio), (int)(originalHeight * ratio)); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IImageStorageService.cs b/src/BCards.Web/Services/IImageStorageService.cs new file mode 100644 index 0000000..0641a71 --- /dev/null +++ b/src/BCards.Web/Services/IImageStorageService.cs @@ -0,0 +1,34 @@ +namespace BCards.Web.Services; + +public interface IImageStorageService +{ + /// + /// Salva uma imagem no storage, com redimensionamento automático para 400x400px + /// + /// Bytes da imagem original + /// Nome original do arquivo + /// Tipo de conteúdo da imagem + /// ID único da imagem salva + Task SaveImageAsync(byte[] imageBytes, string fileName, string contentType); + + /// + /// Recupera os bytes de uma imagem pelo ID + /// + /// ID da imagem + /// Bytes da imagem ou null se não encontrada + Task GetImageAsync(string imageId); + + /// + /// Remove uma imagem do storage + /// + /// ID da imagem + /// True se removida com sucesso + Task DeleteImageAsync(string imageId); + + /// + /// Verifica se uma imagem existe no storage + /// + /// ID da imagem + /// True se a imagem existe + Task ImageExistsAsync(string imageId); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/LivePageService.cs b/src/BCards.Web/Services/LivePageService.cs index e82c617..e376e1f 100644 --- a/src/BCards.Web/Services/LivePageService.cs +++ b/src/BCards.Web/Services/LivePageService.cs @@ -55,7 +55,7 @@ public class LivePageService : ILivePageService Slug = userPage.Slug, DisplayName = userPage.DisplayName, Bio = userPage.Bio, - ProfileImage = userPage.ProfileImage, + ProfileImageId = userPage.ProfileImageId, BusinessType = userPage.BusinessType, Theme = userPage.Theme, Links = userPage.Links, diff --git a/src/BCards.Web/Services/SeoService.cs b/src/BCards.Web/Services/SeoService.cs index 51e2016..74dd8d6 100644 --- a/src/BCards.Web/Services/SeoService.cs +++ b/src/BCards.Web/Services/SeoService.cs @@ -20,7 +20,7 @@ public class SeoService : ISeoService Keywords = GenerateKeywords(userPage, category), OgTitle = GeneratePageTitle(userPage, category), OgDescription = GeneratePageDescription(userPage, category), - OgImage = !string.IsNullOrEmpty(userPage.ProfileImage) ? userPage.ProfileImage : $"{_baseUrl}/images/default-og.png", + OgImage = !string.IsNullOrEmpty(userPage.ProfileImageId) ? userPage.ProfileImageUrl : $"{_baseUrl}/images/default-og.png", CanonicalUrl = GenerateCanonicalUrl(userPage), TwitterCard = "summary_large_image" }; diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index 35223d3..ea2ac7c 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -35,6 +35,10 @@ public class ManagePageViewModel public string InstagramUrl { get; set; } = string.Empty; public List Links { get; set; } = new(); + + // Profile image fields + public string? ProfileImageId { get; set; } + public IFormFile? ProfileImageFile { get; set; } // Data for dropdowns and selections public List AvailableCategories { get; set; } = new(); @@ -43,6 +47,13 @@ public class ManagePageViewModel // Plan limitations public int MaxLinksAllowed { get; set; } = 3; public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); + + /// + /// URL da imagem de perfil ou imagem padrão se não houver upload + /// + public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) + ? $"/api/image/{ProfileImageId}" + : "/images/default-avatar.svg"; } public class ManageLinkViewModel diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index f07d053..69df0aa 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -17,7 +17,7 @@
-
+ @@ -108,6 +108,38 @@
Máximo 200 caracteres
+ +
+
+
+ + +
+ + Formatos aceitos: JPG, PNG, GIF. Máximo: 5MB. Será redimensionada para 400x400px. +
+ +
+
+
+
+
+ Preview +
+ +
+
+
+
+
+ + +