feat/live-preview #8
@ -21,7 +21,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||||
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
|
<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="Testcontainers.MongoDb" Version="3.6.0" />
|
||||||
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
<PackageReference Include="Stripe.net" Version="48.4.0" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="Stripe.net" Version="48.4.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" 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 IModerationService _moderationService;
|
||||||
private readonly IEmailService _emailService;
|
private readonly IEmailService _emailService;
|
||||||
private readonly ILivePageService _livePageService;
|
private readonly ILivePageService _livePageService;
|
||||||
|
private readonly IImageStorageService _imageStorage;
|
||||||
private readonly ILogger<AdminController> _logger;
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
|
||||||
public AdminController(
|
public AdminController(
|
||||||
@ -30,6 +31,7 @@ public class AdminController : Controller
|
|||||||
IModerationService moderationService,
|
IModerationService moderationService,
|
||||||
IEmailService emailService,
|
IEmailService emailService,
|
||||||
ILivePageService livePageService,
|
ILivePageService livePageService,
|
||||||
|
IImageStorageService imageStorage,
|
||||||
ILogger<AdminController> logger)
|
ILogger<AdminController> logger)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
@ -39,6 +41,7 @@ public class AdminController : Controller
|
|||||||
_moderationService = moderationService;
|
_moderationService = moderationService;
|
||||||
_emailService = emailService;
|
_emailService = emailService;
|
||||||
_livePageService = livePageService;
|
_livePageService = livePageService;
|
||||||
|
_imageStorage = imageStorage;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,14 +69,14 @@ public class AdminController : Controller
|
|||||||
var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id);
|
var livePage = await _livePageService.GetLivePageFromUserPageId(page.Id);
|
||||||
if (livePage != null)
|
if (livePage != null)
|
||||||
{
|
{
|
||||||
listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0 , TotalClicks = livePage.Analytics?.TotalClicks ?? 0 });
|
listCounts.Add(page.Id, new { TotalViews = livePage.Analytics?.TotalViews ?? 0, TotalClicks = livePage.Analytics?.TotalClicks ?? 0 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) });
|
listCounts.Add(page.Id, new { TotalViews = (long)(page.Analytics?.TotalViews ?? 0), TotalClicks = (long)(page.Analytics?.TotalClicks ?? 0) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var dashboardModel = new DashboardViewModel
|
var dashboardModel = new DashboardViewModel
|
||||||
{
|
{
|
||||||
@ -189,6 +192,36 @@ public class AdminController : Controller
|
|||||||
ModelState.Remove<ManagePageViewModel>(x => x.TwitterUrl);
|
ModelState.Remove<ManagePageViewModel>(x => x.TwitterUrl);
|
||||||
ModelState.Remove<ManagePageViewModel>(x => x.WhatsAppNumber);
|
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)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("ModelState is invalid:");
|
_logger.LogWarning("ModelState is invalid:");
|
||||||
@ -198,6 +231,8 @@ public class AdminController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Repopulate dropdowns
|
// Repopulate dropdowns
|
||||||
|
var slug = await _userPageService.GenerateSlugAsync(model.Category, model.DisplayName);
|
||||||
|
model.Slug = slug;
|
||||||
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
|
||||||
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
|
||||||
return View(model);
|
return View(model);
|
||||||
@ -276,6 +311,12 @@ public class AdminController : Controller
|
|||||||
return RedirectToAction("Dashboard");
|
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);
|
UpdateUserPageFromModel(existingPage, model);
|
||||||
|
|
||||||
// Set status to PendingModeration for updates
|
// Set status to PendingModeration for updates
|
||||||
@ -581,6 +622,7 @@ public class AdminController : Controller
|
|||||||
Bio = page.Bio,
|
Bio = page.Bio,
|
||||||
Slug = page.Slug,
|
Slug = page.Slug,
|
||||||
SelectedTheme = page.Theme?.Name ?? "minimalist",
|
SelectedTheme = page.Theme?.Name ?? "minimalist",
|
||||||
|
ProfileImageId = page.ProfileImageId,
|
||||||
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
|
Links = page.Links?.Select((l, index) => new ManageLinkViewModel
|
||||||
{
|
{
|
||||||
Id = $"link_{index}",
|
Id = $"link_{index}",
|
||||||
@ -617,6 +659,7 @@ public class AdminController : Controller
|
|||||||
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
|
Slug = SlugHelper.CreateSlug(model.Slug.ToLower()),
|
||||||
Theme = theme,
|
Theme = theme,
|
||||||
Status = ViewModels.PageStatus.Active,
|
Status = ViewModels.PageStatus.Active,
|
||||||
|
ProfileImageId = model.ProfileImageId,
|
||||||
Links = new List<LinkItem>()
|
Links = new List<LinkItem>()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -704,6 +747,7 @@ public class AdminController : Controller
|
|||||||
page.BusinessType = model.BusinessType;
|
page.BusinessType = model.BusinessType;
|
||||||
page.Bio = model.Bio;
|
page.Bio = model.Bio;
|
||||||
page.Slug = model.Slug;
|
page.Slug = model.Slug;
|
||||||
|
page.ProfileImageId = model.ProfileImageId; // CRUCIAL: Atualizar ProfileImageId
|
||||||
page.UpdatedAt = DateTime.UtcNow;
|
page.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
// Update links
|
// 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 Slug { get; }
|
||||||
string DisplayName { get; }
|
string DisplayName { get; }
|
||||||
string Bio { get; }
|
string Bio { get; }
|
||||||
string ProfileImage { get; }
|
string? ProfileImageId { get; }
|
||||||
string BusinessType { get; }
|
string BusinessType { get; }
|
||||||
PageTheme Theme { get; }
|
PageTheme Theme { get; }
|
||||||
List<LinkItem> Links { get; }
|
List<LinkItem> Links { get; }
|
||||||
@ -20,7 +20,8 @@
|
|||||||
string Language { get; }
|
string Language { get; }
|
||||||
DateTime CreatedAt { get; }
|
DateTime CreatedAt { get; }
|
||||||
|
|
||||||
// Propriedade calculada comum
|
// Propriedades calculadas comuns
|
||||||
string FullUrl { get; }
|
string FullUrl { get; }
|
||||||
|
string ProfileImageUrl { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,14 @@ public class LivePage : IPageDisplay
|
|||||||
[BsonElement("bio")]
|
[BsonElement("bio")]
|
||||||
public string Bio { get; set; } = string.Empty;
|
public string Bio { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("profileImageId")]
|
||||||
|
public string? ProfileImageId { get; set; }
|
||||||
|
|
||||||
|
// Campo antigo - ignorar durante deserialização para compatibilidade
|
||||||
[BsonElement("profileImage")]
|
[BsonElement("profileImage")]
|
||||||
public string ProfileImage { get; set; } = string.Empty;
|
[BsonIgnoreIfDefault]
|
||||||
|
[BsonIgnore]
|
||||||
|
public string? ProfileImage { get; set; }
|
||||||
|
|
||||||
[BsonElement("businessType")]
|
[BsonElement("businessType")]
|
||||||
public string BusinessType { get; set; } = string.Empty;
|
public string BusinessType { get; set; } = string.Empty;
|
||||||
@ -60,6 +66,14 @@ public class LivePage : IPageDisplay
|
|||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
public string FullUrl => $"page/{Category}/{Slug}";
|
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
|
public class LivePageAnalytics
|
||||||
|
|||||||
@ -29,8 +29,14 @@ public class UserPage : IPageDisplay
|
|||||||
[BsonElement("bio")]
|
[BsonElement("bio")]
|
||||||
public string Bio { get; set; } = string.Empty;
|
public string Bio { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("profileImageId")]
|
||||||
|
public string? ProfileImageId { get; set; }
|
||||||
|
|
||||||
|
// Campo antigo - ignorar durante deserialização para compatibilidade
|
||||||
[BsonElement("profileImage")]
|
[BsonElement("profileImage")]
|
||||||
public string ProfileImage { get; set; } = string.Empty;
|
[BsonIgnoreIfDefault]
|
||||||
|
[BsonIgnore]
|
||||||
|
public string? ProfileImage { get; set; }
|
||||||
|
|
||||||
[BsonElement("theme")]
|
[BsonElement("theme")]
|
||||||
public PageTheme Theme { get; set; } = new();
|
public PageTheme Theme { get; set; } = new();
|
||||||
@ -87,4 +93,12 @@ public class UserPage : IPageDisplay
|
|||||||
public int PreviewViewCount { get; set; } = 0;
|
public int PreviewViewCount { get; set; } = 0;
|
||||||
|
|
||||||
public string FullUrl => $"page/{Category}/{Slug}";
|
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 Microsoft.AspNetCore.Authentication.OAuth;
|
||||||
using SendGrid;
|
using SendGrid;
|
||||||
using BCards.Web.Middleware;
|
using BCards.Web.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@ -126,6 +127,18 @@ builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
|||||||
builder.Services.AddScoped<IModerationService, ModerationService>();
|
builder.Services.AddScoped<IModerationService, ModerationService>();
|
||||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
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
|
// 🔥 NOVO: LivePage Services
|
||||||
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
|
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
|
||||||
builder.Services.AddScoped<ILivePageService, LivePageService>();
|
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,
|
Slug = userPage.Slug,
|
||||||
DisplayName = userPage.DisplayName,
|
DisplayName = userPage.DisplayName,
|
||||||
Bio = userPage.Bio,
|
Bio = userPage.Bio,
|
||||||
ProfileImage = userPage.ProfileImage,
|
ProfileImageId = userPage.ProfileImageId,
|
||||||
BusinessType = userPage.BusinessType,
|
BusinessType = userPage.BusinessType,
|
||||||
Theme = userPage.Theme,
|
Theme = userPage.Theme,
|
||||||
Links = userPage.Links,
|
Links = userPage.Links,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class SeoService : ISeoService
|
|||||||
Keywords = GenerateKeywords(userPage, category),
|
Keywords = GenerateKeywords(userPage, category),
|
||||||
OgTitle = GeneratePageTitle(userPage, category),
|
OgTitle = GeneratePageTitle(userPage, category),
|
||||||
OgDescription = GeneratePageDescription(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),
|
CanonicalUrl = GenerateCanonicalUrl(userPage),
|
||||||
TwitterCard = "summary_large_image"
|
TwitterCard = "summary_large_image"
|
||||||
};
|
};
|
||||||
|
|||||||
@ -36,6 +36,10 @@ public class ManagePageViewModel
|
|||||||
|
|
||||||
public List<ManageLinkViewModel> Links { get; set; } = new();
|
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
|
// Data for dropdowns and selections
|
||||||
public List<Category> AvailableCategories { get; set; } = new();
|
public List<Category> AvailableCategories { get; set; } = new();
|
||||||
public List<PageTheme> AvailableThemes { get; set; } = new();
|
public List<PageTheme> AvailableThemes { get; set; } = new();
|
||||||
@ -43,6 +47,13 @@ public class ManagePageViewModel
|
|||||||
// Plan limitations
|
// Plan limitations
|
||||||
public int MaxLinksAllowed { get; set; } = 3;
|
public int MaxLinksAllowed { get; set; } = 3;
|
||||||
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
|
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
|
public class ManageLinkViewModel
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-body">
|
<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="Id" type="hidden">
|
||||||
<input asp-for="IsNewPage" type="hidden">
|
<input asp-for="IsNewPage" type="hidden">
|
||||||
|
|
||||||
@ -108,6 +108,38 @@
|
|||||||
<div class="form-text">Máximo 200 caracteres</div>
|
<div class="form-text">Máximo 200 caracteres</div>
|
||||||
</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">
|
<div class="text-end">
|
||||||
<button type="button" class="btn btn-primary" onclick="nextStep(2)">
|
<button type="button" class="btn btn-primary" onclick="nextStep(2)">
|
||||||
Próximo <i class="fas fa-arrow-right ms-1"></i>
|
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: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
border-radius: 4px;
|
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>
|
</style>
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
@ -804,6 +879,9 @@
|
|||||||
// Initialize social media fields
|
// Initialize social media fields
|
||||||
initializeSocialMedia();
|
initializeSocialMedia();
|
||||||
|
|
||||||
|
// Initialize image upload
|
||||||
|
initializeImageUpload();
|
||||||
|
|
||||||
// Check for validation errors and show toast + open accordion
|
// Check for validation errors and show toast + open accordion
|
||||||
checkValidationErrors();
|
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>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -114,16 +114,16 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card h-100 border-0 shadow-sm">
|
<div class="card h-100 border-0 shadow-sm">
|
||||||
<div class="card-body text-center">
|
<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;">
|
class="rounded-circle mb-3" style="width: 60px; height: 60px; object-fit: cover;">
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="bg-primary bg-opacity-10 rounded-circle d-inline-flex align-items-center justify-content-center mb-3"
|
<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;">
|
style="width: 60px; height: 60px;">
|
||||||
<i class="fs-4 text-primary">👤</i>
|
<i class="fas fa-id-card text-primary"></i>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h6 class="card-title">@(page.DisplayName)</h6>
|
<h6 class="card-title">@(page.DisplayName)</h6>
|
||||||
|
|||||||
@ -39,16 +39,16 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image {
|
.profile-image-large {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
|
||||||
border: 4px solid var(--primary-color);
|
border: 4px solid var(--primary-color);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image-placeholder {
|
.profile-icon-placeholder {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -404,8 +404,8 @@
|
|||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image,
|
.profile-image-large,
|
||||||
.profile-image-placeholder {
|
.profile-icon-placeholder {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
@ -449,8 +449,8 @@
|
|||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-image,
|
.profile-image-large,
|
||||||
.profile-image-placeholder {
|
.profile-icon-placeholder {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,21 +46,23 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-lg-6 col-md-8">
|
<div class="col-lg-6 col-md-8">
|
||||||
<div class="profile-card text-center mx-auto">
|
<div class="profile-card mx-auto">
|
||||||
<!-- Profile Image -->
|
<!-- Profile Image & Info -->
|
||||||
@if (!string.IsNullOrEmpty(Model.ProfileImage))
|
<div class="profile-header text-center mb-4">
|
||||||
|
@if (!string.IsNullOrEmpty(Model.ProfileImageId))
|
||||||
{
|
{
|
||||||
<img src="@Model.ProfileImage" alt="@Model.DisplayName" class="profile-image mb-3">
|
<img src="@Model.ProfileImageUrl" alt="@Model.DisplayName" class="profile-image-large rounded-circle mb-3">
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="profile-image-placeholder mb-3 mx-auto">
|
<div class="profile-icon-placeholder mb-3">
|
||||||
👤
|
<i class="fas fa-id-card"></i>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<h1 class="profile-name mb-0">@Model.DisplayName</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Profile Info -->
|
<div class="text-center">
|
||||||
<h1 class="profile-name">@Model.DisplayName</h1>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.Bio))
|
@if (!string.IsNullOrEmpty(Model.Bio))
|
||||||
{
|
{
|
||||||
@ -228,7 +230,9 @@
|
|||||||
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
|
Criado com <a href="@Url.Action("Index", "Home")">BCards</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
</div> <!-- /text-center -->
|
||||||
|
</div> <!-- /profile-card -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,6 +27,22 @@
|
|||||||
margin: 0 auto;
|
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 {
|
.profile-image-placeholder {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
@ -152,6 +168,15 @@
|
|||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-image-small {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-name {
|
.profile-name {
|
||||||
font-size: 1.75rem;
|
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