feat: imagens!!!! Agora tenho uma imagem no topo!
This commit is contained in:
parent
9e7ea6ed9a
commit
c824e9da1c
@ -21,7 +21,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
|
||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
|
||||
|
||||
@ -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<AdminController> _logger;
|
||||
|
||||
public AdminController(
|
||||
@ -30,6 +31,7 @@ public class AdminController : Controller
|
||||
IModerationService moderationService,
|
||||
IEmailService emailService,
|
||||
ILivePageService livePageService,
|
||||
IImageStorageService imageStorage,
|
||||
ILogger<AdminController> 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<ManagePageViewModel>(x => x.TwitterUrl);
|
||||
ModelState.Remove<ManagePageViewModel>(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:");
|
||||
@ -198,6 +231,8 @@ public class AdminController : Controller
|
||||
}
|
||||
|
||||
// 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<LinkItem>()
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
202
src/BCards.Web/Controllers/ImageController.cs
Normal file
202
src/BCards.Web/Controllers/ImageController.cs
Normal file
@ -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<ImageController> _logger;
|
||||
|
||||
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
|
||||
{
|
||||
_imageStorage = imageStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{imageId}")]
|
||||
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
|
||||
}
|
||||
}
|
||||
@ -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<LinkItem> Links { get; }
|
||||
@ -20,7 +20,8 @@
|
||||
string Language { get; }
|
||||
DateTime CreatedAt { get; }
|
||||
|
||||
// Propriedade calculada comum
|
||||
// Propriedades calculadas comuns
|
||||
string FullUrl { get; }
|
||||
string ProfileImageUrl { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[BsonIgnore]
|
||||
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
|
||||
? $"/api/image/{ProfileImageId}"
|
||||
: "/images/default-avatar.svg";
|
||||
}
|
||||
|
||||
public class LivePageAnalytics
|
||||
|
||||
@ -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}";
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
[BsonIgnore]
|
||||
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
|
||||
? $"/api/image/{ProfileImageId}"
|
||||
: "/images/default-avatar.svg";
|
||||
}
|
||||
@ -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<IOpenGraphService, OpenGraphService>();
|
||||
builder.Services.AddScoped<IModerationService, ModerationService>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
|
||||
// Image Storage Service
|
||||
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
|
||||
|
||||
// Configure upload limits for file uploads
|
||||
builder.Services.Configure<FormOptions>(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<ILivePageRepository, LivePageRepository>();
|
||||
builder.Services.AddScoped<ILivePageService, LivePageService>();
|
||||
|
||||
199
src/BCards.Web/Services/GridFSImageStorage.cs
Normal file
199
src/BCards.Web/Services/GridFSImageStorage.cs
Normal file
@ -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<GridFSImageStorage> _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<GridFSImageStorage> logger)
|
||||
{
|
||||
_database = database;
|
||||
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
|
||||
{
|
||||
BucketName = "profile_images"
|
||||
});
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> 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<byte[]?> 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<bool> 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<bool> ImageExistsAsync(string imageId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(imageId))
|
||||
return false;
|
||||
|
||||
if (!ObjectId.TryParse(imageId, out var objectId))
|
||||
return false;
|
||||
|
||||
var filter = Builders<GridFSFileInfo>.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<byte[]> 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<SixLabors.ImageSharp.PixelFormats.Rgb24>(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));
|
||||
}
|
||||
}
|
||||
34
src/BCards.Web/Services/IImageStorageService.cs
Normal file
34
src/BCards.Web/Services/IImageStorageService.cs
Normal file
@ -0,0 +1,34 @@
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
public interface IImageStorageService
|
||||
{
|
||||
/// <summary>
|
||||
/// Salva uma imagem no storage, com redimensionamento automático para 400x400px
|
||||
/// </summary>
|
||||
/// <param name="imageBytes">Bytes da imagem original</param>
|
||||
/// <param name="fileName">Nome original do arquivo</param>
|
||||
/// <param name="contentType">Tipo de conteúdo da imagem</param>
|
||||
/// <returns>ID único da imagem salva</returns>
|
||||
Task<string> SaveImageAsync(byte[] imageBytes, string fileName, string contentType);
|
||||
|
||||
/// <summary>
|
||||
/// Recupera os bytes de uma imagem pelo ID
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>Bytes da imagem ou null se não encontrada</returns>
|
||||
Task<byte[]?> GetImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove uma imagem do storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se removida com sucesso</returns>
|
||||
Task<bool> DeleteImageAsync(string imageId);
|
||||
|
||||
/// <summary>
|
||||
/// Verifica se uma imagem existe no storage
|
||||
/// </summary>
|
||||
/// <param name="imageId">ID da imagem</param>
|
||||
/// <returns>True se a imagem existe</returns>
|
||||
Task<bool> ImageExistsAsync(string imageId);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
};
|
||||
|
||||
@ -36,6 +36,10 @@ public class ManagePageViewModel
|
||||
|
||||
public List<ManageLinkViewModel> Links { get; set; } = new();
|
||||
|
||||
// Profile image fields
|
||||
public string? ProfileImageId { get; set; }
|
||||
public IFormFile? ProfileImageFile { get; set; }
|
||||
|
||||
// Data for dropdowns and selections
|
||||
public List<Category> AvailableCategories { get; set; } = new();
|
||||
public List<PageTheme> AvailableThemes { 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());
|
||||
|
||||
/// <summary>
|
||||
/// URL da imagem de perfil ou imagem padrão se não houver upload
|
||||
/// </summary>
|
||||
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
|
||||
? $"/api/image/{ProfileImageId}"
|
||||
: "/images/default-avatar.svg";
|
||||
}
|
||||
|
||||
public class ManageLinkViewModel
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form asp-action="ManagePage" method="post" id="managePageForm" novalidate>
|
||||
<form asp-action="ManagePage" method="post" id="managePageForm" enctype="multipart/form-data" novalidate>
|
||||
<input asp-for="Id" type="hidden">
|
||||
<input asp-for="IsNewPage" type="hidden">
|
||||
|
||||
@ -108,6 +108,38 @@
|
||||
<div class="form-text">Máximo 200 caracteres</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Image Upload -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-camera me-2"></i>
|
||||
Foto de Perfil (Opcional)
|
||||
</label>
|
||||
<input type="file" class="form-control" id="profileImageInput" name="ProfileImageFile" accept="image/*">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Formatos aceitos: JPG, PNG, GIF. Máximo: 5MB. Será redimensionada para 400x400px.
|
||||
</div>
|
||||
<span class="text-danger" id="imageError"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<div class="profile-image-preview">
|
||||
<img id="imagePreview" src="@Model.ProfileImageUrl" alt="Preview" class="img-thumbnail profile-preview-img">
|
||||
<div class="mt-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="removeImageBtn" style="@(string.IsNullOrEmpty(Model.ProfileImageId) ? "display: none;" : "")">
|
||||
<i class="fas fa-trash"></i> Remover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" asp-for="ProfileImageId" id="profileImageId">
|
||||
|
||||
<div class="text-end">
|
||||
<button type="button" class="btn btn-primary" onclick="nextStep(2)">
|
||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
||||
@ -791,6 +823,49 @@
|
||||
border: 1px solid rgba(0, 0, 0, 0.125);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Profile Image Upload Styles */
|
||||
.profile-image-preview {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(13, 110, 253, 0.02);
|
||||
}
|
||||
|
||||
.profile-image-preview:hover {
|
||||
border-color: #007bff;
|
||||
background: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
|
||||
.profile-preview-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 50% !important;
|
||||
border: 4px solid #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.profile-preview-img:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
#profileImageInput {
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
#profileImageInput:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
#imageError {
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@section Scripts {
|
||||
@ -804,6 +879,9 @@
|
||||
// Initialize social media fields
|
||||
initializeSocialMedia();
|
||||
|
||||
// Initialize image upload
|
||||
initializeImageUpload();
|
||||
|
||||
// Check for validation errors and show toast + open accordion
|
||||
checkValidationErrors();
|
||||
|
||||
@ -1469,6 +1547,61 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image Upload Functions
|
||||
function initializeImageUpload() {
|
||||
const fileInput = $('#profileImageInput');
|
||||
const preview = $('#imagePreview');
|
||||
const removeBtn = $('#removeImageBtn');
|
||||
const errorSpan = $('#imageError');
|
||||
const hiddenField = $('#profileImageId');
|
||||
|
||||
// Preview da imagem selecionada
|
||||
fileInput.on('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
errorSpan.text('');
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Validações client-side
|
||||
if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) {
|
||||
errorSpan.text('Formato inválido. Use apenas JPG, PNG ou GIF.');
|
||||
fileInput.val('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) { // 5MB
|
||||
errorSpan.text('Arquivo muito grande. Máximo 5MB.');
|
||||
fileInput.val('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.attr('src', e.target.result);
|
||||
removeBtn.show();
|
||||
markStepComplete(1); // Marcar step 1 como completo
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Remover imagem
|
||||
removeBtn.on('click', function() {
|
||||
if (confirm('Tem certeza que deseja remover a imagem de perfil?')) {
|
||||
fileInput.val('');
|
||||
hiddenField.val('');
|
||||
preview.attr('src', '/images/default-avatar.svg');
|
||||
removeBtn.hide();
|
||||
errorSpan.text('');
|
||||
}
|
||||
});
|
||||
|
||||
// Mostrar botão remover se já tem imagem
|
||||
if (hiddenField.val()) {
|
||||
removeBtn.show();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
}
|
||||
|
||||
|
||||
@ -114,16 +114,16 @@
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body text-center">
|
||||
@if (!string.IsNullOrEmpty(page.ProfileImage))
|
||||
@if (!string.IsNullOrEmpty(page.ProfileImageId))
|
||||
{
|
||||
<img src="@(page.ProfileImage)" alt="@(page.DisplayName)"
|
||||
<img src="@(page.ProfileImageUrl)" alt="@(page.DisplayName)"
|
||||
class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
|
||||
style="width: 60px; height: 60px;">
|
||||
<i class="fs-4 text-primary">👤</i>
|
||||
<i class="fas fa-id-card text-primary"></i>
|
||||
</div>
|
||||
}
|
||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
||||
|
||||
@ -39,16 +39,16 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
.profile-image-large {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 4px solid var(--primary-color);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image-placeholder {
|
||||
.profile-icon-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
@ -404,8 +404,8 @@
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.profile-image,
|
||||
.profile-image-placeholder {
|
||||
.profile-image-large,
|
||||
.profile-icon-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
@ -449,8 +449,8 @@
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.profile-image,
|
||||
.profile-image-placeholder {
|
||||
.profile-image-large,
|
||||
.profile-icon-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
@ -46,21 +46,23 @@
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6 col-md-8">
|
||||
<div class="profile-card text-center mx-auto">
|
||||
<!-- Profile Image -->
|
||||
@if (!string.IsNullOrEmpty(Model.ProfileImage))
|
||||
{
|
||||
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-image-placeholder mb-3 mx-auto">
|
||||
👤
|
||||
</div>
|
||||
}
|
||||
<div class="profile-card mx-auto">
|
||||
<!-- Profile Image & Info -->
|
||||
<div class="profile-header text-center mb-4">
|
||||
@if (!string.IsNullOrEmpty(Model.ProfileImageId))
|
||||
{
|
||||
<img src="@Model.ProfileImageUrl" alt="@Model.DisplayName" class="profile-image-large rounded-circle mb-3">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-icon-placeholder mb-3">
|
||||
<i class="fas fa-id-card"></i>
|
||||
</div>
|
||||
}
|
||||
<h1 class="profile-name mb-0">@Model.DisplayName</h1>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<h1 class="profile-name">@Model.DisplayName</h1>
|
||||
<div class="text-center">
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Bio))
|
||||
{
|
||||
@ -228,7 +230,9 @@
|
||||
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /text-center -->
|
||||
</div> <!-- /profile-card -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -27,6 +27,22 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-image-small {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #007bff;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.profile-image-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
@ -152,6 +168,15 @@
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-image-small {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
11
src/BCards.Web/wwwroot/images/default-avatar.svg
Normal file
11
src/BCards.Web/wwwroot/images/default-avatar.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="200" cy="200" r="200" fill="#f8f9fa"/>
|
||||
|
||||
<!-- User icon -->
|
||||
<circle cx="200" cy="160" r="50" fill="#6c757d"/>
|
||||
<path d="M200 220c-40 0-75 20-90 50h180c-15-30-50-50-90-50z" fill="#6c757d"/>
|
||||
|
||||
<!-- Border -->
|
||||
<circle cx="200" cy="200" r="199" stroke="#dee2e6" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 449 B |
Loading…
Reference in New Issue
Block a user