From f61b48fa4baa3697cfafbbaae148cc38bedc0295 Mon Sep 17 00:00:00 2001 From: Manuel Date: Sun, 10 Aug 2025 20:07:40 +0200 Subject: [PATCH] back photo + event tags and persons --- .vscode/launch.json | 8 + .vscode/tasks.json | 16 ++ back/Constants.cs | 4 - back/DTO/PhotoFormModel.cs | 6 +- back/DataModels/EventModel.cs | 52 ++++ back/DataModels/GalleryModel.cs | 27 ++ back/DataModels/PermissionModel.cs | 34 +++ back/DataModels/PersonModel.cs | 49 ++++ back/DataModels/PhotoModel.cs | 68 +++++ back/DataModels/RankingModel.cs | 36 +++ back/DataModels/RoleModel.cs | 69 +++++ back/DataModels/TagModel.cs | 14 ++ back/DataModels/UserModel.cs | 38 +++ back/Options/DatabaseConfig.cs | 14 ++ back/Options/Databases.cs | 6 + back/Program.cs | 8 +- back/ServicesExtensions/DatabaseContexts.cs | 37 +++ .../DdContextOptionsBuilderExtensions.cs | 78 ++++++ back/ServicesExtensions/Options.cs | 72 ++++++ back/ServicesExtensions/ServicesExtensions.cs | 21 ++ back/appsettings.Development.json | 14 +- back/appsettings.Production.json | 18 ++ back/back.csproj | 30 +++ back/context/EventContext.cs | 52 ++++ back/context/GalleryContext.cs | 48 ++++ back/context/PersonContext.cs | 52 ++++ back/context/PhotoContext.cs | 236 ++++++++++++++++-- back/context/TagContext.cs | 52 ++++ back/context/UserContext.cs | 48 ++++ back/controllers/PhotosController.cs | 92 +++---- back/models/Photo.cs | 96 ------- .../blob/FileSystemImageStorageService.cs | 104 ++++++++ back/persistance/blob/IBlobStorageService.cs | 11 + back/photos.db-shm | Bin 32768 -> 0 bytes back/photos.db-wal | 0 back/services/ImageResizer/IImageResizer.cs | 6 + back/services/ImageResizer/ImageResizer.cs | 23 ++ front/v2/public/assets/icons/clean_svgs.bat | 2 + front/v2/public/config.json | 2 +- front/v2/src/app/app.routes.ts | 1 + front/v2/src/app/app.ts | 4 +- .../app/global-components/header/header.scss | 1 + .../v2/src/app/views/home-view/home-view.html | 2 +- .../v2/src/app/views/home-view/home-view.scss | 49 ++++ front/v2/src/app/views/home-view/home-view.ts | 4 +- front/v2/src/utils/paginator.ts | 23 +- 46 files changed, 1438 insertions(+), 189 deletions(-) delete mode 100644 back/Constants.cs create mode 100644 back/DataModels/EventModel.cs create mode 100644 back/DataModels/GalleryModel.cs create mode 100644 back/DataModels/PermissionModel.cs create mode 100644 back/DataModels/PersonModel.cs create mode 100644 back/DataModels/PhotoModel.cs create mode 100644 back/DataModels/RankingModel.cs create mode 100644 back/DataModels/RoleModel.cs create mode 100644 back/DataModels/TagModel.cs create mode 100644 back/DataModels/UserModel.cs create mode 100644 back/Options/DatabaseConfig.cs create mode 100644 back/Options/Databases.cs create mode 100644 back/ServicesExtensions/DatabaseContexts.cs create mode 100644 back/ServicesExtensions/DdContextOptionsBuilderExtensions.cs create mode 100644 back/ServicesExtensions/Options.cs create mode 100644 back/ServicesExtensions/ServicesExtensions.cs create mode 100644 back/appsettings.Production.json create mode 100644 back/context/EventContext.cs create mode 100644 back/context/GalleryContext.cs create mode 100644 back/context/PersonContext.cs create mode 100644 back/context/TagContext.cs create mode 100644 back/context/UserContext.cs delete mode 100644 back/models/Photo.cs create mode 100644 back/persistance/blob/FileSystemImageStorageService.cs create mode 100644 back/persistance/blob/IBlobStorageService.cs delete mode 100644 back/photos.db-shm delete mode 100644 back/photos.db-wal create mode 100644 back/services/ImageResizer/IImageResizer.cs create mode 100644 back/services/ImageResizer/ImageResizer.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 52f0f67..49e0f5e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,14 @@ "webRoot": "${workspaceFolder}/front/v2", "preLaunchTask": "Start Node server with nvs latest" }, + { + "name": "(legacy) FRONT: DEBUG(Edge)", + "request": "launch", + "type": "msedge", + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}/front/v1", + "preLaunchTask": "(legacy) Start Node server with nvs latest" + }, { "name": "Attach Edge", "type": "msedge", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ae96cf1..c3feb3c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,22 @@ { "version": "2.0.0", "tasks": [ + { + "label": "(legacy) Start Node server with nvs latest", + "type": "shell", + "command": "nvs use latest && npm run start", + "options": { + "cwd": "${workspaceFolder}/front/v1" + }, + "isBackground": true, + "problemMatcher": [], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared" + } + }, { "label": "Start Node server with nvs latest", "type": "shell", diff --git a/back/Constants.cs b/back/Constants.cs deleted file mode 100644 index f6d845e..0000000 --- a/back/Constants.cs +++ /dev/null @@ -1,4 +0,0 @@ -public static class Constants -{ - public const string Data = "data"; -} diff --git a/back/DTO/PhotoFormModel.cs b/back/DTO/PhotoFormModel.cs index 7562d2f..6a295e4 100644 --- a/back/DTO/PhotoFormModel.cs +++ b/back/DTO/PhotoFormModel.cs @@ -2,11 +2,13 @@ public class PhotoFormModel { + public required string UserId { get; set; } public required string Title { get; set; } public string? Description { get; set; } - public string? Tags { get; set; } - public string? People { get; set; } + public string[]? Tags { get; set; } + public string[]? People { get; set; } public IFormFile? Image { get; set; } public string? Ubicacion { get; set; } public string? Evento { get; set; } + public bool IsPublic { get; set; } } diff --git a/back/DataModels/EventModel.cs b/back/DataModels/EventModel.cs new file mode 100644 index 0000000..63a1487 --- /dev/null +++ b/back/DataModels/EventModel.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Events")] +public class EventModel +{ + [Key] + public string Id { get; set; } + [Required, MaxLength(50)] + public string? Title { get; set; } + [MaxLength(500)] + public string? Description { get; set; } + public DateTime? Date { get; set; } + public string? Location { get; set; } + public List RelatedTags { get; set; } = []; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } + + public EventModel(string id) + { + Id = id; + } + + public EventModel( + string id, + string title, + string description, + DateTime date, + string location, + List? relatedTags = null, + DateTime? createdAt = null, + DateTime? updatedAt = null, + string? createdBy = null, + string? updatedBy = null) + { + Id = id; + Title = title; + Description = description; + Date = date; + Location = location; + RelatedTags = relatedTags ?? []; + CreatedAt = createdAt ?? DateTime.UtcNow; + UpdatedAt = updatedAt ?? DateTime.UtcNow; + CreatedBy = createdBy ?? ""; + UpdatedBy = updatedBy; + } +} + diff --git a/back/DataModels/GalleryModel.cs b/back/DataModels/GalleryModel.cs new file mode 100644 index 0000000..b758d26 --- /dev/null +++ b/back/DataModels/GalleryModel.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Galleries")] +public class GalleryModel +{ + [Key] + public string Id { get; set; } + [MaxLength(100)] + public string? Title { get; set; } + [MaxLength(500)] + public string? Description { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } + public List Photos { get; set; } = new(); + public bool? IsPublic { get; set; } = true; + public bool? IsArchived { get; set; } = false; + public bool? IsFavorite { get; set; } = false; + public EventModel? Event { get; set; } = null; + public List? Tags { get; set; } = null; + public List? PersonsInvolved { get; set; } = null; + public List? UsersWhoCanSee { get; set; } = null; +} diff --git a/back/DataModels/PermissionModel.cs b/back/DataModels/PermissionModel.cs new file mode 100644 index 0000000..725c462 --- /dev/null +++ b/back/DataModels/PermissionModel.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Permissions")] +public class PermissionModel +{ + [Key] + public string Id { get; set; } + [Required, MaxLength(100)] + public string Name { get; set; } + [MaxLength(255)] + public string Description { get; set; } + + public PermissionModel(string id, string name, string description) + { + Id = id; + Name = name; + Description = description; + } + + // Static permissions + public static readonly PermissionModel ViewContentPermission = new("1", "VIEW_CONTENT", "Permission to view content"); + public static readonly PermissionModel LikeContentPermission = new("2", "LIKE_CONTENT", "Permission to like content"); + public static readonly PermissionModel EditContentPermission = new("3", "EDIT_CONTENT", "Permission to edit content"); + public static readonly PermissionModel DeleteContentPermission = new("4", "DELETE_CONTENT", "Permission to delete content"); + public static readonly PermissionModel CreateContentPermission = new("5", "CREATE_CONTENT", "Permission to create new content"); + public static readonly PermissionModel EditUserPermission = new("6", "EDIT_USER", "Permission to edit user"); + public static readonly PermissionModel DeleteUserPermission = new("7", "DELETE_USER", "Permission to delete user"); + public static readonly PermissionModel DisableUserPermission = new("8", "DISABLE_USER", "Permission to disable user"); + public static readonly PermissionModel CreateUserPermission = new("9", "CREATE_USER", "Permission to create new user"); + public static readonly PermissionModel EditWebConfigPermission = new("10", "EDIT_WEB_CONFIG", "Permission to edit web configuration"); +} diff --git a/back/DataModels/PersonModel.cs b/back/DataModels/PersonModel.cs new file mode 100644 index 0000000..36bda3b --- /dev/null +++ b/back/DataModels/PersonModel.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Persons")] +public class PersonModel +{ + [Key] + public string Id { get; set; } + [Required, MaxLength(100)] + public string? Name { get; set; } + public string? ProfilePicture { get; set; } + public string? Avatar { get; set; } + public SocialMedia? SocialMedia { get; set; } + [MaxLength(250)] + public string? Bio { get; set; } // Optional field for a short biography or description + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } + + public PersonModel(string id) + { + Id = id; + } + + public PersonModel(string id, string name, string? profilePicture = null, string? avatar = null, SocialMedia? socialMedia = null) + { + Id = id; + Name = name; + ProfilePicture = profilePicture; + Avatar = avatar; + SocialMedia = socialMedia; + } +} + +[Table("SocialMediaLinks")] +public class SocialMedia +{ + public string? Facebook { get; set; } + public string? Instagram { get; set; } + public string? Twitter { get; set; } + public string? BlueSky { get; set; } + public string? Tiktok { get; set; } + public string? Linkedin { get; set; } + public string? Pinterest { get; set; } + public string? Discord { get; set; } + public string? Reddit { get; set; } + public string? Other { get; set; } +} diff --git a/back/DataModels/PhotoModel.cs b/back/DataModels/PhotoModel.cs new file mode 100644 index 0000000..103d975 --- /dev/null +++ b/back/DataModels/PhotoModel.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Photo")] +public class PhotoModel +{ + [Key] + public string Id { get; set; } + [Required, MaxLength(100), MinLength(1)] + public string Title { get; set; } + [MaxLength(500)] + public string Description { get; set; } + public string Extension { get; set; } + public string LowResUrl { get; set; } + public string MidResUrl { get; set; } + public string HighResUrl { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public string CreatedBy { get; set; } + public string UpdatedBy { get; set; } + public EventModel? Event { get; set; } = null; + public List Tags { get; set; } = []; + public RankingModel Ranking { get; set; } = new RankingModel(0); + public bool IsFavorite { get; set; } = false; + public bool IsPublic { get; set; } = true; + public bool IsArchived { get; set; } = false; + public List? Persons { get; set; } + + public PhotoModel( + string id, + string title, + string description, + string lowResUrl, + string midResUrl, + string highResUrl, + DateTime createdAt, + DateTime updatedAt, + string createdBy, + string updatedBy, + EventModel? @event = null, + List? tags = null, + RankingModel? ranking = null, + bool isFavorite = false, + bool isPublic = true, + bool isArchived = false, + List? persons = null) + { + Id = id; + Title = title; + Description = description; + LowResUrl = lowResUrl; + MidResUrl = midResUrl; + HighResUrl = highResUrl; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + CreatedBy = createdBy; + UpdatedBy = updatedBy; + Event = @event ?? Event; + Tags = tags ?? Tags; + Ranking = ranking ?? Ranking; + IsFavorite = isFavorite; + IsPublic = isPublic; + IsArchived = isArchived; + Persons = persons ?? Persons; + } +} \ No newline at end of file diff --git a/back/DataModels/RankingModel.cs b/back/DataModels/RankingModel.cs new file mode 100644 index 0000000..3150392 --- /dev/null +++ b/back/DataModels/RankingModel.cs @@ -0,0 +1,36 @@ +namespace back.DataModels; + +public class RankingModel +{ + private int totalVotes; + private int upVotes; + private int downVotes; + + public RankingModel(int totalVotes, int upVotes = 0, int downVotes = 0) + { + this.totalVotes = totalVotes; + this.upVotes = upVotes; + this.downVotes = downVotes; + } + + public void DownVote() + { + downVotes++; + totalVotes++; + } + + public void UpVote() + { + upVotes++; + totalVotes++; + } + + public double Score + { + get + { + if (totalVotes == 0) return 0; + return (double)(upVotes - downVotes) / totalVotes; + } + } +} diff --git a/back/DataModels/RoleModel.cs b/back/DataModels/RoleModel.cs new file mode 100644 index 0000000..c74a9db --- /dev/null +++ b/back/DataModels/RoleModel.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; + +namespace back.DataModels; + +[Tags("Roles")] +public class RoleModel +{ + [Key] + public string Id { get; set; } + [Required, MaxLength(100)] + public string Name { get; set; } + [MaxLength(250)] + public string Description { get; set; } + public List Permissions { get; set; } + public RoleModel? BaseRoleModel { get; set; } + + public RoleModel(string id, string name, string description, List? permissions = null, RoleModel? baseRoleModel = null) + { + Id = id; + Name = name; + Description = description; + Permissions = permissions ?? new List(); + BaseRoleModel = baseRoleModel; + if (baseRoleModel != null) + { + Permissions.AddRange(baseRoleModel.Permissions); + } + } + + public bool IsAdmin => Id == AdminRole.Id; + public bool IsContentManager => Id == ContentManagerRole.Id; + public bool IsUser => Id == UserRole.Id; + + public bool HasPermission(PermissionModel permission) + { + return Permissions.Exists(p => p.Id == permission.Id); + } + + public static readonly RoleModel UserRole = new( + "1", "User", "Role for regular users", + new List { + PermissionModel.ViewContentPermission, + PermissionModel.LikeContentPermission + } + ); + + public static readonly RoleModel ContentManagerRole = new( + "2", "Content Manager", "Role for managing content", + new List { + PermissionModel.CreateUserPermission, + PermissionModel.DisableUserPermission, + PermissionModel.CreateContentPermission, + PermissionModel.EditContentPermission, + PermissionModel.DeleteContentPermission + }, + UserRole + ); + + public static readonly RoleModel AdminRole = new( + "3", "Admin", "Administrator role with full permissions", + new List { + PermissionModel.CreateUserPermission, + PermissionModel.EditUserPermission, + PermissionModel.DeleteUserPermission, + PermissionModel.EditWebConfigPermission + }, + ContentManagerRole + ); +} diff --git a/back/DataModels/TagModel.cs b/back/DataModels/TagModel.cs new file mode 100644 index 0000000..a23c10a --- /dev/null +++ b/back/DataModels/TagModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Tags")] +public class TagModel(string id, string? name = null) +{ + [Key] + public string Id { get; init; } = id; + [Required, MaxLength(25)] + public string? Name { get; init; } = name; + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; +} diff --git a/back/DataModels/UserModel.cs b/back/DataModels/UserModel.cs new file mode 100644 index 0000000..389c997 --- /dev/null +++ b/back/DataModels/UserModel.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace back.DataModels; + +[Table("Users")] +public class UserModel : PersonModel +{ + [Required] + public string Email { get; set; } + [Required, MinLength(8)] + public string Password { get; set; } + public List Role { get; set; } + + public UserModel(string id, string email, string password, string name, List role, DateTime createdAt, DateTime updatedAt) + : base(id, name) + { + Email = email; + Password = password; + Role = role; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + } + + public bool IsAdmin => Role.Exists(r => r.IsAdmin); + public bool IsContentManager => Role.Exists(r => r.IsContentManager); + public bool IsUser => Role.Exists(r => r.IsUser); + + public static readonly UserModel DefaultUser = new( + "0", + "default@example.com", + string.Empty, + "Default User", + [RoleModel.UserRole], + DateTime.UtcNow, + DateTime.UtcNow + ); +} diff --git a/back/Options/DatabaseConfig.cs b/back/Options/DatabaseConfig.cs new file mode 100644 index 0000000..b39c84c --- /dev/null +++ b/back/Options/DatabaseConfig.cs @@ -0,0 +1,14 @@ +namespace back.Options; + +public sealed class DatabaseConfig +{ + public const string BlobStorage = "Databases:Blob"; + public const string DataStorage = "Databases:Data"; + public required string Provider { get; set; } + public string? DatabaseName { get; set; } + public string? AccountKey { get; set; } + public string? TokenCredential { get; set; } + public string? BaseUrl { get; set; } + public string? ConnectionString { get; set; } + public string? SystemContainer { get; set; } +} diff --git a/back/Options/Databases.cs b/back/Options/Databases.cs new file mode 100644 index 0000000..f3bddbd --- /dev/null +++ b/back/Options/Databases.cs @@ -0,0 +1,6 @@ +namespace back.Options; + +public sealed class Databases +{ + public string? BaseDirectory { get; set; } +} diff --git a/back/Program.cs b/back/Program.cs index 2cf0cf5..939dd89 100644 --- a/back/Program.cs +++ b/back/Program.cs @@ -1,5 +1,4 @@ -using back.ApiService.context; -using Microsoft.EntityFrameworkCore; +using back.ServicesExtensions; namespace back; @@ -9,9 +8,8 @@ public class Program { var builder = WebApplication.CreateBuilder(args); - Directory.CreateDirectory(Constants.Data); - // Add services to the container. - builder.Services.AddDbContext(options => options.UseSqlite($"Data Source={Constants.Data}/photos.db")); + builder.Services.UseExtensions(); + builder.Services.AddMemoryCache(); builder.Services.AddControllers(); // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi diff --git a/back/ServicesExtensions/DatabaseContexts.cs b/back/ServicesExtensions/DatabaseContexts.cs new file mode 100644 index 0000000..be93b51 --- /dev/null +++ b/back/ServicesExtensions/DatabaseContexts.cs @@ -0,0 +1,37 @@ +using back.context; +using back.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace back.ServicesExtensions; + +public static partial class ServicesExtensions +{ + private static IServiceCollection AddDatabaseContexts(this IServiceCollection services) + { + services + .AddContext() + .AddContext() + .AddContext() + .AddContext() + .AddContext() + .AddContext() + ; + + return services; + } + private static IServiceCollection AddContext(this IServiceCollection services) + where T : DbContext + { + var config = services + .BuildServiceProvider() + .GetRequiredService>() + .Get(DatabaseConfig.DataStorage); + + services.AddDbContext(options => + { + options.UseDatabaseConfig(config); + }); + return services; + } +} diff --git a/back/ServicesExtensions/DdContextOptionsBuilderExtensions.cs b/back/ServicesExtensions/DdContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..cd54909 --- /dev/null +++ b/back/ServicesExtensions/DdContextOptionsBuilderExtensions.cs @@ -0,0 +1,78 @@ +using back.Options; +using Microsoft.EntityFrameworkCore; + +namespace back.ServicesExtensions; + +public enum DatabaseProvider +{ + /* -- Relational databases supported by EF Core -- */ + SUPPORTED, // Placeholder for supported databases. + InMemory, + Sqlite, + PostgreSQL, + CockroachDB, // CockroachDB is compatible with PostgreSQL. + SQLServer, + MariaDB, + MySQL, + Oracle, // Oracle is supported by EF Core but requires a separate package. + + /* -- NoSQL are not supported by EF -- */ + + NOT_SUPPORTED, // Placeholder for unsupported databases. + Firebird, // Firebird is supported by EF Core but requires a separate package. + Db2, // Db2 is supported by EF Core but requires a separate package. + SAPHana, // SAP HANA is supported by EF Core but requires a separate package. + Sybase, // Sybase is supported by EF Core but requires a separate package. + Cosmos, // Cosmos DB is database supported by EF Core. + MongoDB, + InfluxDB, + Redis, + Cassandra, + ElasticSearch, + CouchDB, + RavenDB, + Neo4j, + OrientDB, + ArangoDB, + ClickHouse, + Druid, + TimescaleDB, +} + +public static partial class DbContextOptionsBuilderExtensions +{ + private static string SupportedDbs() + => string.Join(", ", Enum.GetValues() + .Where(db => db > DatabaseProvider.SUPPORTED && db < DatabaseProvider.NOT_SUPPORTED) + .OrderBy(db => db) + .Select(db => db.ToString())); + + public static void UseDatabaseConfig(this DbContextOptionsBuilder options, DatabaseConfig config) + { + if(!Enum.TryParse(Enum.GetName(typeof(DatabaseProvider), config.Provider)?.ToLowerInvariant(), out DatabaseProvider provider)) + { + throw new InvalidOperationException($"Unsupported database provider: {config.Provider} -- Supported providers are: {SupportedDbs()}"); + } + + switch (provider) + { + case DatabaseProvider.Sqlite: + options.UseSqlite(config.ConnectionString); + break; + case DatabaseProvider.InMemory: + options.UseInMemoryDatabase(config.ConnectionString); + break; + case DatabaseProvider.PostgreSQL or DatabaseProvider.CockroachDB: + options.UseNpgsql(config.ConnectionString); + break; + case DatabaseProvider.SQLServer: + options.UseSqlServer(config.ConnectionString); + break; + case DatabaseProvider.MySQL or DatabaseProvider.MariaDB: + options.UseMySql(config.ConnectionString, ServerVersion.AutoDetect(config.ConnectionString)); + break; + default: + throw new InvalidOperationException($"Unsupported database provider: {config.Provider}"); + } + } +} diff --git a/back/ServicesExtensions/Options.cs b/back/ServicesExtensions/Options.cs new file mode 100644 index 0000000..e0cc5c9 --- /dev/null +++ b/back/ServicesExtensions/Options.cs @@ -0,0 +1,72 @@ +using back.Options; + +namespace back.ServicesExtensions; + +public static partial class ServicesExtensions +{ + private static IConfiguration ConfigureOptions(this IServiceCollection services) + { + IConfiguration config = services.BuildServiceProvider().GetRequiredService(); + string? baseDirectory = null; + + services.Configure(config.GetSection(nameof(Databases))); + services.Configure(DatabaseConfig.DataStorage, config.GetSection(DatabaseConfig.DataStorage)); + services.Configure(DatabaseConfig.BlobStorage, config.GetSection(DatabaseConfig.BlobStorage)); + + services.PostConfigure(databases => + { + if (databases.BaseDirectory != null && !Directory.Exists(databases.BaseDirectory)) + { + try + { + Directory.CreateDirectory(databases.BaseDirectory); + Console.WriteLine($"Base directory created at: {databases.BaseDirectory}"); + baseDirectory = databases.BaseDirectory; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to create base directory at {databases.BaseDirectory}. " + + "Please ensure the path is valid and accessible.", ex + ); + } + } + }); + + services.PostConfigure(DatabaseConfig.DataStorage, config => + { + PostConfigureDatabaseConfig(config, baseDirectory); + }); + + services.PostConfigure(DatabaseConfig.BlobStorage, config => + { + PostConfigureDatabaseConfig(config, baseDirectory); + }); + + return config; + } + + private static void PostConfigureDatabaseConfig(DatabaseConfig config, string? baseDirectory) + { + if (!string.IsNullOrEmpty(config.SystemContainer)) + { + var path = config.SystemContainer; + if (!string.IsNullOrEmpty(baseDirectory)) + { + path = Path.Combine(baseDirectory, path); + } + try + { + Directory.CreateDirectory(path); + Console.WriteLine($"System container for {config.Provider} created at: {path}"); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to create system container at {path}. " + + "Please ensure the path is valid and accessible.", ex + ); + } + } + } +} diff --git a/back/ServicesExtensions/ServicesExtensions.cs b/back/ServicesExtensions/ServicesExtensions.cs new file mode 100644 index 0000000..94a4518 --- /dev/null +++ b/back/ServicesExtensions/ServicesExtensions.cs @@ -0,0 +1,21 @@ +using back.persistance.blob; +using back.services.ImageResizer; + +namespace back.ServicesExtensions; + +public static partial class ServicesExtensions +{ + public static IServiceCollection UseExtensions(this IServiceCollection services) + { + var config = services.ConfigureOptions(); + + services.AddDatabaseContexts(); + + // TODO: Move and configure for using S3, Azure Blob Storage, etc. + services.AddSingleton(); + + services.AddSingleton(); + + return services; + } +} diff --git a/back/appsettings.Development.json b/back/appsettings.Development.json index 0c208ae..274a61a 100644 --- a/back/appsettings.Development.json +++ b/back/appsettings.Development.json @@ -1,8 +1,14 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Databases": { + "BaseDirectory": "data", + "Data": { + "Provider": "sqlite", + "ConnectionString": "Data Source=data/app.db;Cache=Shared" + }, + "Blob": { + "Provider": "system", + "baseUrl": "https://localhost:7273/api/photo/{id}/{res}", + "SystemContainer": "imgs" } } } diff --git a/back/appsettings.Production.json b/back/appsettings.Production.json new file mode 100644 index 0000000..0bc2373 --- /dev/null +++ b/back/appsettings.Production.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Databases": { + "Data": { + "Provider": "sqlite", + "ConnectionString": "Data Source=data/app.db;Cache=Shared" + }, + "Blob": { + "Provider": "system", + "baseUrl": "https://back.mmorales.photo/api/photo/{id}/{res}" + } + } +} diff --git a/back/back.csproj b/back/back.csproj index 38c764f..d008215 100644 --- a/back/back.csproj +++ b/back/back.csproj @@ -7,12 +7,42 @@ + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/back/context/EventContext.cs b/back/context/EventContext.cs new file mode 100644 index 0000000..8ed947f --- /dev/null +++ b/back/context/EventContext.cs @@ -0,0 +1,52 @@ +using back.DataModels; +using Microsoft.EntityFrameworkCore; + +namespace back.context; + +public class EventContext : DbContext +{ + private DbSet Events { get; set; } + public EventContext(DbContextOptions options) + { + Database.EnsureCreated(); + } + + public async Task GetById(string id) + { + return await GetById(Guid.Parse(id)); + } + + public async Task GetById(Guid id) + { + return await Events.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Events.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Events + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(EventModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Events.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Events.AnyAsync(p => p.Id == id); + } +} diff --git a/back/context/GalleryContext.cs b/back/context/GalleryContext.cs new file mode 100644 index 0000000..b0127c0 --- /dev/null +++ b/back/context/GalleryContext.cs @@ -0,0 +1,48 @@ +using back.DataModels; +using Microsoft.EntityFrameworkCore; + +namespace back.context; + +public class GalleryContext : DbContext +{ + public DbSet Galleries { get; set; } + public GalleryContext(DbContextOptions options) : base(options) + { + // Ensure database is created + Database.EnsureCreated(); + } + + public async Task GetById(Guid id) + { + return await Galleries.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Galleries.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Galleries + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(GalleryModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Galleries.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Galleries.AnyAsync(p => p.Id == id); + } +} \ No newline at end of file diff --git a/back/context/PersonContext.cs b/back/context/PersonContext.cs new file mode 100644 index 0000000..12f6c35 --- /dev/null +++ b/back/context/PersonContext.cs @@ -0,0 +1,52 @@ +using back.DataModels; +using Microsoft.EntityFrameworkCore; + +namespace back.context; + +public class PersonContext : DbContext +{ + private DbSet Persons { get; set; } + public PersonContext(DbContextOptions options) + { + Database.EnsureCreated(); + } + + public async Task GetById(string id) + { + return await GetById(Guid.Parse(id)); + } + + public async Task GetById(Guid id) + { + return await Persons.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Persons.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Persons + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(PersonModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Persons.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Persons.AnyAsync(p => p.Id == id); + } +} diff --git a/back/context/PhotoContext.cs b/back/context/PhotoContext.cs index cb97609..0df9a92 100644 --- a/back/context/PhotoContext.cs +++ b/back/context/PhotoContext.cs @@ -1,29 +1,235 @@ -using back.ApiService.models; +using back.DataModels; +using back.DTO; +using back.persistance.blob; +using back.services.ImageResizer; using Microsoft.EntityFrameworkCore; -namespace back.ApiService.context; +namespace back.context; public class PhotoContext : DbContext { - public DbSet Photos { get; set; } + private DbSet Photos { get; set; } + private readonly IImageResizer _Resizer; + private readonly IBlobStorageService _BlobStorage; - public PhotoContext(DbContextOptions options) : base(options) + private readonly TagContext _tagContext; + private readonly EventContext _eventContext; + private readonly PersonContext _personContext; + + public PhotoContext(DbContextOptions options, + IImageResizer resizer, + IBlobStorageService blobStorage, + TagContext tags, + EventContext events, + PersonContext persons + ) : base(options) { // Ensure database is created Database.EnsureCreated(); + _Resizer = resizer; + _BlobStorage = blobStorage; + _tagContext = tags; + _eventContext = events; + _personContext = persons; } - protected override void OnModelCreating(ModelBuilder modelBuilder) + public async Task CreateNew(PhotoFormModel? form) { - modelBuilder.Entity() - .Property(p => p.Tags) - .HasConversion( - v => string.Join(',', v), - v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); - modelBuilder.Entity() - .Property(p => p.PersonsIn) - .HasConversion( - v => string.Join(',', v), - v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); + if (form == null) { return; } + + var photo = new PhotoModel( + Guid.NewGuid().ToString(), + form.Title, + form.Description ?? string.Empty, + string.Empty, // LowResUrl will be set later + string.Empty, // MidResUrl will be set later + string.Empty, // HighResUrl will be set later + DateTime.UtcNow, + DateTime.UtcNow, + form.UserId, + form.UserId + ) + { + IsPublic = form.IsPublic + }; + + List tasks = [ + SaveBlob(photo, form), + LinkTags(photo, form.Tags ?? [], form.UserId), + LinkEvent(photo, form.Evento ?? "", form.UserId), + LinkPersons(photo, form.People ?? [], form.UserId), + ]; + + await Task.WhenAll(tasks); + await Photos.AddAsync(photo); + await SaveChangesAsync(); + } + + private async Task LinkPersons(PhotoModel photo, string[] personas, string updatedBy = "SYSTEM") + { + if (photo == null || personas == null || personas.Length == 0) return; + foreach (var personId in personas) + { + var person = await _personContext.GetById(personId); + if (person != null) + { + await LinkPersons(photo, person, updatedBy); + } + } + } + + private async Task LinkPersons(PhotoModel photo, PersonModel tag, string updatedBy = "SYSTEM") + { + if (tag == null) return; + // Ensure the tag exists + if (await _personContext.Exists(tag.Id)) + { + photo.Persons ??= []; + photo.Persons.Add(tag); + photo.UpdatedAt = DateTime.UtcNow; + photo.UpdatedBy = updatedBy; // or use a more appropriate value + } + } + + private async Task LinkTags(PhotoModel photo, string[] tags, string updatedBy = "SYSTEM") + { + if (photo == null || tags == null || tags.Length == 0) return; + foreach (var tagId in tags) + { + var tag = await _tagContext.GetById(tagId); + if (tag != null) + { + await LinkTag(photo, tag, updatedBy); + } + } + } + + private async Task LinkTag(PhotoModel photo, TagModel tag, string updatedBy = "SYSTEM") + { + if (tag == null) return; + // Ensure the tag exists + if (await _tagContext.Exists(tag.Id)) + { + photo.Tags.Add(tag); + photo.UpdatedAt = DateTime.UtcNow; + photo.UpdatedBy = updatedBy; // or use a more appropriate value + } + } + + private async Task LinkEvent(PhotoModel photo, string eventId, string updatedBy = "SYSTEM") + { + if (string.IsNullOrEmpty(eventId)) return; + var evento = await _eventContext.GetById(eventId); + if (evento != null) + { + await LinkEvent(photo, evento, updatedBy); + } + } + + private async Task LinkEvent(PhotoModel photo, EventModel? evento, string updatedBy = "SYSTEM") + { + if (evento == null) return; + // Ensure the event exists + if (await _eventContext.Exists(evento.Id)) + { + photo.Event = evento; + photo.UpdatedAt = DateTime.UtcNow; + photo.UpdatedBy = updatedBy; + } + } + + private async Task SaveBlob(PhotoModel photo, PhotoFormModel form) + { + if (form.Image != null && form.Image.Length > 0) + { + var lowRes = await _Resizer.ResizeImage(form.Image, 480); + var midRes = await _Resizer.ResizeImage(form.Image, 720); + // Upload images to blob storage + photo.Extension = form.Image.FileName.Split('.').Last(); + photo.LowResUrl = $"low/{photo.Id}.webp"; + photo.MidResUrl = $"mid/{photo.Id}.webp"; + photo.HighResUrl = $"high/{photo.Id}.{photo.Extension}"; + await _BlobStorage.SaveAsync(lowRes, photo.LowResUrl); + await _BlobStorage.SaveAsync(midRes, photo.MidResUrl); + await _BlobStorage.SaveAsync(form.Image.OpenReadStream(), photo.HighResUrl); + } + } + + public async Task GetById(string id) + { + return await GetById(Guid.Parse(id)); + } + + public async Task GetById(Guid id) + { + return await Photos.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Photos.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Photos + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(PhotoModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Photos.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Photos.AnyAsync(p => p.Id == id); + } + + public async Task Delete(PhotoModel photo) + { + if (photo == null) return; + if (await Exists(photo)) + { + // Delete the photo from blob storage + if (!string.IsNullOrEmpty(photo.LowResUrl)) + await _BlobStorage.DeleteAsync(photo.LowResUrl); + if (!string.IsNullOrEmpty(photo.MidResUrl)) + await _BlobStorage.DeleteAsync(photo.MidResUrl); + if (!string.IsNullOrEmpty(photo.HighResUrl)) + await _BlobStorage.DeleteAsync(photo.HighResUrl); + Photos.Remove(photo); + await SaveChangesAsync(); + } + } + + public async Task Update(PhotoModel photo) + { + if (photo == null) return; + if (await Exists(photo)) + { + var evento = photo.Event; + photo.Event = null; + await LinkEvent(photo, evento, photo.UpdatedBy); + + var tags = photo.Tags.Select(t => t.Id); + photo.Tags.Clear(); + await LinkTags(photo, [.. tags], photo.UpdatedBy); + + var persons = photo.Persons?.Select(t => t.Id) ?? []; + photo.Persons = null; + await LinkPersons(photo, [.. persons], photo.UpdatedBy); + + Photos.Update(photo); + await SaveChangesAsync(); + } } } diff --git a/back/context/TagContext.cs b/back/context/TagContext.cs new file mode 100644 index 0000000..c896972 --- /dev/null +++ b/back/context/TagContext.cs @@ -0,0 +1,52 @@ +using back.DataModels; +using Microsoft.EntityFrameworkCore; + +namespace back.context; + +public class TagContext : DbContext +{ + private DbSet Tags { get; set; } + public TagContext(DbContextOptions options) + { + Database.EnsureCreated(); + } + + public async Task GetById(string id) + { + return await GetById(Guid.Parse(id)); + } + + public async Task GetById(Guid id) + { + return await Tags.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Tags.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Tags + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(TagModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Tags.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Tags.AnyAsync(p => p.Id == id); + } +} diff --git a/back/context/UserContext.cs b/back/context/UserContext.cs new file mode 100644 index 0000000..c16acb8 --- /dev/null +++ b/back/context/UserContext.cs @@ -0,0 +1,48 @@ +using back.DataModels; +using Microsoft.EntityFrameworkCore; + +namespace back.context; + +public class UserContext : DbContext +{ + public DbSet Users { get; set; } + public UserContext(DbContextOptions options) : base(options) + { + // Ensure database is created + Database.EnsureCreated(); + } + + public async Task GetById(Guid id) + { + return await Users.FindAsync(id); + } + + public async Task GetTotalItems() + { + return await Users.CountAsync(); + } + + public async Task> GetPage(int page = 1, int pageSize = 20) + { + if (page < 1) page = 1; + if (pageSize < 1) pageSize = 20; + + return await Users + .OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + } + + public async Task Exists(UserModel? photo) + { + if (photo == null) return false; + if (string.IsNullOrEmpty(photo.Id)) return false; + return await Users.AnyAsync(p => p.Id == photo.Id); + } + + public async Task Exists(string id) + { + return await Users.AnyAsync(p => p.Id == id); + } +} \ No newline at end of file diff --git a/back/controllers/PhotosController.cs b/back/controllers/PhotosController.cs index 373caee..1916a83 100644 --- a/back/controllers/PhotosController.cs +++ b/back/controllers/PhotosController.cs @@ -1,112 +1,98 @@ -using back.ApiService.context; -using back.ApiService.models; +using back.context; +using back.DataModels; using back.DTO; +using back.persistance.blob; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace back.controllers; [Route("api/[controller]")] [ApiController] -public class PhotosController(PhotoContext photoContext) : ControllerBase +public class PhotosController(PhotoContext photoContext, IBlobStorageService blobStorage) : ControllerBase { private readonly PhotoContext _photoContext = photoContext; // GET: api/ [HttpGet] - public async Task>> Get([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + public async Task>> Get([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 20; - - var totalItems = await _photoContext.Photos.CountAsync(); - var photos = await _photoContext.Photos - .OrderByDescending(p => p.CreatedAt) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + var photos = await _photoContext.GetPage(page, pageSize); + var totalItems = await _photoContext.GetTotalItems(); Response.Headers.Append("X-Total-Count", totalItems.ToString()); - return Ok(photos); } // GET api//5 - [HttpGet("{id}/{res}")] - public async Task Get(Guid id, string res = "low") + [HttpGet("{res}/{id}")] + public async Task Get(string res, Guid id) { - var photo = await _photoContext.Photos.FindAsync(id); + var photo = await _photoContext.GetById(id); if (photo == null) return NotFound(); string? filePath = res.ToLower() switch { - "low" => photo.LowResUrl, - "mid" => photo.MidResUrl, "high" => photo.HighResUrl, - _ => null + "mid" => photo.MidResUrl, + "low" or _ => photo.LowResUrl }; - if (filePath == null || !System.IO.File.Exists(Path.Combine(Constants.Data, filePath))) + string? mediaType = res.ToLower() switch + { + "high" => $"image/{photo.Extension}", + "mid" or "low" or _ => "image/webp", + }; + + if (filePath == null) + { + return NotFound(); + } + var file = await blobStorage.GetBytesAsync(filePath); + if (file == null) + { return NotFound(); + } - var fileBytes = await System.IO.File.ReadAllBytesAsync(Path.Combine(Constants.Data, filePath)); - var contentType = "image/jpeg"; // Cambia si usas otro formato - - return File(fileBytes, contentType); + return File(file, mediaType); } // POST api/ [HttpPost] public async Task Post([FromForm] PhotoFormModel form) { - try + try { if (form.Image == null || form.Image.Length == 0) return BadRequest("No image uploaded."); - var photo = PhotoBuilder.Build( - form.Title, - form.Description, - form.Tags?.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), - form.People?.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), - form.Image - ); - - // Guarda la información en la base de datos - _photoContext.Photos.Add(photo); - await _photoContext.SaveChangesAsync(); + await _photoContext.CreateNew(form); return Created(); } - catch + catch { return BadRequest(); } } - //// PUT api//5 - //[HttpPut("{id}")] - //public async Task Put(Guid id, [FromBody] Photo photo) - //{ - // if (id != photo.Id) - // return BadRequest(); - - // _photoContext.Entry(photo).State = EntityState.Modified; - // await _photoContext.SaveChangesAsync(); - // return NoContent(); - //} + //// PUT api/ + [HttpPut] + public async Task Put([FromBody] PhotoModel photo) + { + await _photoContext.Update(photo); + return NoContent(); + } // DELETE api//5 [HttpDelete("{id}")] public async Task Delete(Guid id) { - var photo = await _photoContext.Photos.FindAsync(id); + var photo = await _photoContext.GetById(id); if (photo == null) return NotFound(); - _photoContext.Photos.Remove(photo); - await _photoContext.SaveChangesAsync(); + await _photoContext.Delete(photo); return NoContent(); } } diff --git a/back/models/Photo.cs b/back/models/Photo.cs deleted file mode 100644 index c8e37d5..0000000 --- a/back/models/Photo.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using Microsoft.AspNetCore.Identity; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; - -namespace back.ApiService.models; - -public class PhotoBuilder -{ - public static Photo Build(string? title, string? description, List? tags, List? personsIn, IFormFile image) - { - // Genera un nombre de archivo único - var id = Guid.NewGuid(); - var fileName = $"{id}{Path.GetExtension(image.FileName)}"; - var photo = new Photo(title, description, tags, personsIn, fileName) - { - Id = id - }; - - // Asegura que los directorios existen - Directory.CreateDirectory(Path.Join(Constants.Data, Photo.LowResFolder)); - Directory.CreateDirectory(Path.Join(Constants.Data, Photo.MidResFolder)); - Directory.CreateDirectory(Path.Join(Constants.Data, Photo.HighResFolder)); - - // Procesa y guarda las imágenes - using var stream = image.OpenReadStream(); - using var img = Image.Load(stream); - // Baja resolución (480px) - img.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(480, 0), Mode = ResizeMode.Max })); - img.Save(Path.Join(Constants.Data, photo.LowResUrl)); - - // Media resolución (720px) - img.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(720, 0), Mode = ResizeMode.Max })); - img.Save(Path.Join(Constants.Data, photo.MidResUrl)); - - // Original - stream.Position = 0; - using var original = Image.Load(stream); - original.Save(Path.Join(Constants.Data, photo.HighResUrl)); - - return photo; - } -} - -[Table("Photo")] -public class Photo -{ - public const string LowResFolder = "imgs/low"; - public const string MidResFolder = "imgs/mid"; - public const string HighResFolder = "imgs/high"; - - [Key] - public Guid Id { get; set; } - public string? Title { get; set; } - public string? Description { get; set; } - public string? Ubicación { get; set; } - public string? Evento { get; set; } - public List? Tags { get; set; } - public List? PersonsIn { get; set; } - public string? FileName { get; set; } - public string LowResUrl { get => Path.Join(LowResFolder, FileName); } - public string MidResUrl { get => Path.Join(MidResFolder, FileName); } - public string HighResUrl { get => Path.Join(HighResFolder, FileName); } - public float? Ranking { get; set; } - public DateTime? CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } - public string? CreatedBy { get; set; } - public string? UpdatedBy { get; set; } - - private Photo() - { - //Id = Guid.NewGuid(); - Tags = []; - PersonsIn = []; - CreatedAt = DateTime.Now; - UpdatedAt = DateTime.Now; - CreatedBy = "system"; - UpdatedBy = "system"; - Ranking = 0.0f; - } - - public Photo(string? title, string? description, List? tags, List? personsIn, string? fileName) - : this() - { - if (string.IsNullOrWhiteSpace(fileName)) - { - throw new ArgumentException("FileName cannot be null or empty.", nameof(fileName)); - } - Title = title; - Description = description; - Tags = tags ?? []; - PersonsIn = personsIn ?? []; - FileName = fileName; - } -} diff --git a/back/persistance/blob/FileSystemImageStorageService.cs b/back/persistance/blob/FileSystemImageStorageService.cs new file mode 100644 index 0000000..d08f174 --- /dev/null +++ b/back/persistance/blob/FileSystemImageStorageService.cs @@ -0,0 +1,104 @@ +using back.Options; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace back.persistance.blob; + +public class FileSystemImageStorageService( + IOptions systemOptions, + IOptionsMonitor options, + IMemoryCache memoryCache + ) : IBlobStorageService +{ + private readonly string RootPath = systemOptions.Value.BaseDirectory ?? ""; + private readonly DatabaseConfig config = options.Get(DatabaseConfig.BlobStorage); + private readonly IMemoryCache cache = memoryCache; + + private string GetFullPath(string fileName) + { + // Ensure the directory exists + var directory = Path.Join(RootPath, config.SystemContainer, fileName); + if (directory != null && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return fileName; + } + + public async Task DeleteAsync(string fileName) + { + var path = GetFullPath(fileName); + if (cache.TryGetValue(path, out Stream cachedStream)) + { + cachedStream.Dispose(); // Dispose the cached stream if it exists + cache.Remove(path); // Remove from cache + } + if (!File.Exists(path)) + { + return; // No file to delete + } + try + { + File.Delete(path); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error deleting file {fileName}: {ex.Message}", ex); + } + } + + public Task GetStreamAsync(string fileName) + { + var path = GetFullPath(fileName); + if (File.Exists(path)) + { + if (cache.TryGetValue(path, out Stream? cachedStream)) + { + return Task.FromResult(cachedStream); + } + // open the file stream for multiple reads and cache it for performance + var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + + cache.CreateEntry(path) + .SetValue(fileStream) + .SetSlidingExpiration(TimeSpan.FromMinutes(30)); // Cache for 30 minutes + + return Task.FromResult(fileStream); + } + return Task.FromResult(null); + } + + public async Task GetBytesAsync(string fileName) + { + var stream = await GetStreamAsync(fileName); + if (stream != null) + { + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + return memoryStream.ToArray(); + } + return null; + } + + public async Task SaveAsync(Stream blobStream, string fileName) + { + var path = GetFullPath(fileName); + if (cache.TryGetValue(path, out Stream? _) || File.Exists(path)) + { + throw new InvalidOperationException($"File {fileName} already exists. Use Update for updating file info."); + } + using var fileStream = new FileStream(path, FileMode.Create, FileAccess.Write); + await blobStream.CopyToAsync(fileStream); + } + + public async Task UpdateAsync(Stream blobStream, string fileName) + { + var path = GetFullPath(fileName); + if (File.Exists(path)) + { + await DeleteAsync(fileName); + } + await SaveAsync(blobStream, fileName); + } +} + diff --git a/back/persistance/blob/IBlobStorageService.cs b/back/persistance/blob/IBlobStorageService.cs new file mode 100644 index 0000000..d276416 --- /dev/null +++ b/back/persistance/blob/IBlobStorageService.cs @@ -0,0 +1,11 @@ +namespace back.persistance.blob; + +public interface IBlobStorageService +{ + Task SaveAsync(Stream blobStream, string fileName); + Task GetStreamAsync(string fileName); + Task GetBytesAsync(string fileName); + Task DeleteAsync(string fileName); + Task UpdateAsync(Stream blobStream, string fileName); +} + diff --git a/back/photos.db-shm b/back/photos.db-shm deleted file mode 100644 index fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeIuAr62r3 ResizeImage(IFormFile image, int v); +} diff --git a/back/services/ImageResizer/ImageResizer.cs b/back/services/ImageResizer/ImageResizer.cs new file mode 100644 index 0000000..210656e --- /dev/null +++ b/back/services/ImageResizer/ImageResizer.cs @@ -0,0 +1,23 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace back.services.ImageResizer; + +public sealed class ImageResizer : IImageResizer +{ + public async Task ResizeImage(IFormFile image, int maxRes) + { + if (image == null || image.Length == 0) + { + throw new ArgumentException("Invalid image file."); + } + using var inputStream = image.OpenReadStream(); + using var outputStream = new MemoryStream(); + using var img = Image.Load(inputStream); + + img.Mutate(x => x.Resize(new ResizeOptions { Size = new Size(maxRes, 0), Mode = ResizeMode.Max })); + await img.SaveAsWebpAsync(outputStream); + outputStream.Position = 0; + return outputStream; + } +} \ No newline at end of file diff --git a/front/v2/public/assets/icons/clean_svgs.bat b/front/v2/public/assets/icons/clean_svgs.bat index e69de29..168bb57 100644 --- a/front/v2/public/assets/icons/clean_svgs.bat +++ b/front/v2/public/assets/icons/clean_svgs.bat @@ -0,0 +1,2 @@ +nvs latest +node .\_fix-svg-stroke.js \ No newline at end of file diff --git a/front/v2/public/config.json b/front/v2/public/config.json index e3ee1fd..f594f6c 100644 --- a/front/v2/public/config.json +++ b/front/v2/public/config.json @@ -1,7 +1,7 @@ { "$schema": "config.schema.json", "version": "1.0.0", - "baseApiUrl": "https://back.mmorales.photo/api", + "baseApiUrl": "https://localhost:7273/api", "defaultTheme": "light", "logoAlt": "MMORALES PHOTO", "title": "MMORALES PHOTO", diff --git a/front/v2/src/app/app.routes.ts b/front/v2/src/app/app.routes.ts index 876811c..5baa33e 100644 --- a/front/v2/src/app/app.routes.ts +++ b/front/v2/src/app/app.routes.ts @@ -11,6 +11,7 @@ import { EventsView } from './views/events-view/events-view'; export const routes: Routes = [ { path: '', component: HomeView }, + { path: 'home', component: HomeView }, { path: 'login', component: LoginView }, { path: 'tags', component: TagsView }, { path: 'events', component: EventsView }, diff --git a/front/v2/src/app/app.ts b/front/v2/src/app/app.ts index 0750ed0..512c4d3 100644 --- a/front/v2/src/app/app.ts +++ b/front/v2/src/app/app.ts @@ -1,14 +1,12 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { Header } from './global-components/header/header'; -import { HomeView } from './views/home-view/home-view'; @Component({ selector: 'app-root', - imports: [Header, HomeView, RouterOutlet], + imports: [Header, RouterOutlet], templateUrl: './app.html', styleUrl: './app.scss', }) export class App { - protected isHomeView = true; } diff --git a/front/v2/src/app/global-components/header/header.scss b/front/v2/src/app/global-components/header/header.scss index 17c7c53..de1e423 100644 --- a/front/v2/src/app/global-components/header/header.scss +++ b/front/v2/src/app/global-components/header/header.scss @@ -11,6 +11,7 @@ header { align-items: center; align-content: center; gap: 0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); logo { width: 30%; diff --git a/front/v2/src/app/views/home-view/home-view.html b/front/v2/src/app/views/home-view/home-view.html index 84c6a4b..93d114a 100644 --- a/front/v2/src/app/views/home-view/home-view.html +++ b/front/v2/src/app/views/home-view/home-view.html @@ -1,5 +1,5 @@
    - @if (gallery.photos) { @for (item of gallery.photos; track $index) { + @if (gallery().photos) { @for (item of gallery().photos; track $index) {
  • diff --git a/front/v2/src/app/views/home-view/home-view.scss b/front/v2/src/app/views/home-view/home-view.scss index e69de29..eeec7fe 100644 --- a/front/v2/src/app/views/home-view/home-view.scss +++ b/front/v2/src/app/views/home-view/home-view.scss @@ -0,0 +1,49 @@ +$gap-size: 20px; + +.low-res-image-list { + columns: 4; + column-gap: $gap-size; + list-style: none; + margin-top: 3%; + min-height: 100lvh; + box-sizing: border-box; + width: 100%; + padding: 0; +} + +/* Mobile - 1 column */ +@media (max-width: 639px) { + .low-res-image-list { + columns: 1; + } +} + +/* Tablets in portrait - 3 columns for better balance */ +@media (min-width: 640px) and (max-width: 1023px) and (orientation: portrait) { + .low-res-image-list { + columns: 3; + } +} + +/* Tablets in landscape - 1 column */ +@media (min-width: 640px) and (max-width: 1023px) and (orientation: landscape) { + .low-res-image-list { + columns: 1; + max-width: 90%; + min-height: 95vh; + display: flex; + flex-direction: column; + justify-content: center; + } +} + +/* Desktop - mosaic with margins */ +@media (min-width: 1024px) { + .low-res-image-list { + columns: 4; + margin-left: 17svw; + margin-right: 17svw; + max-width: calc(100vw - 34svw); + overflow: visible; + } +} diff --git a/front/v2/src/app/views/home-view/home-view.ts b/front/v2/src/app/views/home-view/home-view.ts index fa5d507..f2afac6 100644 --- a/front/v2/src/app/views/home-view/home-view.ts +++ b/front/v2/src/app/views/home-view/home-view.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, signal } from '@angular/core'; import { homeGallery } from '../../../models/gallery/homeGallery'; @Component({ @@ -8,7 +8,7 @@ import { homeGallery } from '../../../models/gallery/homeGallery'; styleUrl: './home-view.scss', }) export class HomeView implements OnInit { - public gallery: homeGallery = new homeGallery(); + public readonly gallery = signal(new homeGallery()); ngOnInit() {} } diff --git a/front/v2/src/utils/paginator.ts b/front/v2/src/utils/paginator.ts index 39cfa97..18982d2 100644 --- a/front/v2/src/utils/paginator.ts +++ b/front/v2/src/utils/paginator.ts @@ -26,15 +26,30 @@ export class paginator { private params: Record = {}; private page: number = 1; - private totalItems: number = 0; + private totalItems: number = 20; private isLoading: boolean = false; + private alreadyLoadedPages: Record = {}; get totalPages(): number { return Math.ceil(this.totalItems / this.maxItemPerPage); } get hasNextPage(): boolean { - return this.page < this.totalPages; + return this.page <= this.totalPages; + } + + public async getPage(number: number): Promise { + if (number < 1) { + return this.alreadyLoadedPages[1] || null; + } + if (number > this.totalPages) { + if (this.hasNextPage) { + this.loadNextPage(); + return this.alreadyLoadedPages[number] || null; + } + return this.alreadyLoadedPages[this.totalPages] || null; + } + return this.alreadyLoadedPages[number] || null; } public async loadNextPage(): Promise { @@ -54,8 +69,10 @@ export class paginator { this.totalItems = response.headers['x-total-count'] ? parseInt(response.headers['x-total-count'], 10) : 0; + const data = response.data as T[]; + this.alreadyLoadedPages[this.page] = data; this.page++; - return response.data as T[]; + return data; } catch (error) { console.error('Error loading photos:', error); } finally {