feat/live-preview #8

Merged
ricardo merged 43 commits from feat/live-preview into main 2025-08-18 00:50:03 +00:00
19 changed files with 745 additions and 40 deletions
Showing only changes of commit c824e9da1c - Show all commits

View File

@ -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" />

View File

@ -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" />

View File

@ -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:");
@ -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<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

View 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;
}
}

View File

@ -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; }
}
}

View File

@ -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

View File

@ -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";
}

View File

@ -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>();

View 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));
}
}

View 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);
}

View File

@ -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,

View File

@ -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"
};

View File

@ -35,6 +35,10 @@ public class ManagePageViewModel
public string InstagramUrl { get; set; } = string.Empty;
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();
@ -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

View File

@ -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>
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}

View 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