back photo + event tags and persons

This commit is contained in:
2025-08-10 20:07:40 +02:00
parent 0cc8bddfa1
commit f61b48fa4b
46 changed files with 1438 additions and 189 deletions

8
.vscode/launch.json vendored
View File

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

16
.vscode/tasks.json vendored
View File

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

View File

@@ -1,4 +0,0 @@
public static class Constants
{
public const string Data = "data";
}

View File

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

View File

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

View File

@@ -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<PhotoModel> 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<TagModel>? Tags { get; set; } = null;
public List<PersonModel>? PersonsInvolved { get; set; } = null;
public List<PersonModel>? UsersWhoCanSee { get; set; } = null;
}

View File

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

View File

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

View File

@@ -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<TagModel> 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<PersonModel>? 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<TagModel>? tags = null,
RankingModel? ranking = null,
bool isFavorite = false,
bool isPublic = true,
bool isArchived = false,
List<PersonModel>? 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;
}
}

View File

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

View File

@@ -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<PermissionModel> Permissions { get; set; }
public RoleModel? BaseRoleModel { get; set; }
public RoleModel(string id, string name, string description, List<PermissionModel>? permissions = null, RoleModel? baseRoleModel = null)
{
Id = id;
Name = name;
Description = description;
Permissions = permissions ?? new List<PermissionModel>();
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> {
PermissionModel.ViewContentPermission,
PermissionModel.LikeContentPermission
}
);
public static readonly RoleModel ContentManagerRole = new(
"2", "Content Manager", "Role for managing content",
new List<PermissionModel> {
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> {
PermissionModel.CreateUserPermission,
PermissionModel.EditUserPermission,
PermissionModel.DeleteUserPermission,
PermissionModel.EditWebConfigPermission
},
ContentManagerRole
);
}

View File

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

View File

@@ -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<RoleModel> Role { get; set; }
public UserModel(string id, string email, string password, string name, List<RoleModel> 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
);
}

View File

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

View File

@@ -0,0 +1,6 @@
namespace back.Options;
public sealed class Databases
{
public string? BaseDirectory { get; set; }
}

View File

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

View File

@@ -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<EventContext>()
.AddContext<GalleryContext>()
.AddContext<PersonContext>()
.AddContext<PhotoContext>()
.AddContext<TagContext>()
.AddContext<UserContext>()
;
return services;
}
private static IServiceCollection AddContext<T>(this IServiceCollection services)
where T : DbContext
{
var config = services
.BuildServiceProvider()
.GetRequiredService<IOptionsSnapshot<DatabaseConfig>>()
.Get(DatabaseConfig.DataStorage);
services.AddDbContext<T>(options =>
{
options.UseDatabaseConfig(config);
});
return services;
}
}

View File

@@ -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<DatabaseProvider>()
.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}");
}
}
}

View File

@@ -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<IConfiguration>();
string? baseDirectory = null;
services.Configure<Databases>(config.GetSection(nameof(Databases)));
services.Configure<DatabaseConfig>(DatabaseConfig.DataStorage, config.GetSection(DatabaseConfig.DataStorage));
services.Configure<DatabaseConfig>(DatabaseConfig.BlobStorage, config.GetSection(DatabaseConfig.BlobStorage));
services.PostConfigure<Databases>(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>(DatabaseConfig.DataStorage, config =>
{
PostConfigureDatabaseConfig(config, baseDirectory);
});
services.PostConfigure<DatabaseConfig>(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
);
}
}
}
}

View File

@@ -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<IBlobStorageService, FileSystemImageStorageService>();
services.AddSingleton<IImageResizer, ImageResizer>();
return services;
}
}

View File

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

View File

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

View File

@@ -7,12 +7,42 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.UnitOfWork" Version="3.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Oracle.EntityFrameworkCore" Version="9.23.90" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
<PackageReference Include="System.Diagnostics.EventLog" Version="9.0.8" />
<PackageReference Include="System.Text.Json" Version="9.0.8" />
</ItemGroup>
<ItemGroup>
<Folder Include="persistance\data\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,52 @@
using back.DataModels;
using Microsoft.EntityFrameworkCore;
namespace back.context;
public class EventContext : DbContext
{
private DbSet<EventModel> Events { get; set; }
public EventContext(DbContextOptions<EventContext> options)
{
Database.EnsureCreated();
}
public async Task<EventModel?> GetById(string id)
{
return await GetById(Guid.Parse(id));
}
public async Task<EventModel?> GetById(Guid id)
{
return await Events.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Events.CountAsync();
}
public async Task<IEnumerable<EventModel>> 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<bool> 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<bool> Exists(string id)
{
return await Events.AnyAsync(p => p.Id == id);
}
}

View File

@@ -0,0 +1,48 @@
using back.DataModels;
using Microsoft.EntityFrameworkCore;
namespace back.context;
public class GalleryContext : DbContext
{
public DbSet<GalleryModel> Galleries { get; set; }
public GalleryContext(DbContextOptions<GalleryContext> options) : base(options)
{
// Ensure database is created
Database.EnsureCreated();
}
public async Task<GalleryModel?> GetById(Guid id)
{
return await Galleries.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Galleries.CountAsync();
}
public async Task<IEnumerable<GalleryModel>> 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<bool> 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<bool> Exists(string id)
{
return await Galleries.AnyAsync(p => p.Id == id);
}
}

View File

@@ -0,0 +1,52 @@
using back.DataModels;
using Microsoft.EntityFrameworkCore;
namespace back.context;
public class PersonContext : DbContext
{
private DbSet<PersonModel> Persons { get; set; }
public PersonContext(DbContextOptions<PersonContext> options)
{
Database.EnsureCreated();
}
public async Task<PersonModel?> GetById(string id)
{
return await GetById(Guid.Parse(id));
}
public async Task<PersonModel?> GetById(Guid id)
{
return await Persons.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Persons.CountAsync();
}
public async Task<IEnumerable<PersonModel>> 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<bool> 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<bool> Exists(string id)
{
return await Persons.AnyAsync(p => p.Id == id);
}
}

View File

@@ -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<Photo> Photos { get; set; }
private DbSet<PhotoModel> Photos { get; set; }
private readonly IImageResizer _Resizer;
private readonly IBlobStorageService _BlobStorage;
public PhotoContext(DbContextOptions<PhotoContext> options) : base(options)
private readonly TagContext _tagContext;
private readonly EventContext _eventContext;
private readonly PersonContext _personContext;
public PhotoContext(DbContextOptions<PhotoContext> 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<Photo>()
.Property(p => p.Tags)
.HasConversion(
v => string.Join(',', v),
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
modelBuilder.Entity<Photo>()
.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<Task> 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<PhotoModel?> GetById(string id)
{
return await GetById(Guid.Parse(id));
}
public async Task<PhotoModel?> GetById(Guid id)
{
return await Photos.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Photos.CountAsync();
}
public async Task<IEnumerable<PhotoModel>> 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<bool> 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<bool> 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();
}
}
}

View File

@@ -0,0 +1,52 @@
using back.DataModels;
using Microsoft.EntityFrameworkCore;
namespace back.context;
public class TagContext : DbContext
{
private DbSet<TagModel> Tags { get; set; }
public TagContext(DbContextOptions<TagContext> options)
{
Database.EnsureCreated();
}
public async Task<TagModel?> GetById(string id)
{
return await GetById(Guid.Parse(id));
}
public async Task<TagModel?> GetById(Guid id)
{
return await Tags.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Tags.CountAsync();
}
public async Task<IEnumerable<TagModel>> 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<bool> 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<bool> Exists(string id)
{
return await Tags.AnyAsync(p => p.Id == id);
}
}

View File

@@ -0,0 +1,48 @@
using back.DataModels;
using Microsoft.EntityFrameworkCore;
namespace back.context;
public class UserContext : DbContext
{
public DbSet<UserModel> Users { get; set; }
public UserContext(DbContextOptions<UserContext> options) : base(options)
{
// Ensure database is created
Database.EnsureCreated();
}
public async Task<UserModel?> GetById(Guid id)
{
return await Users.FindAsync(id);
}
public async Task<int> GetTotalItems()
{
return await Users.CountAsync();
}
public async Task<IEnumerable<UserModel>> 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<bool> 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<bool> Exists(string id)
{
return await Users.AnyAsync(p => p.Id == id);
}
}

View File

@@ -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/<PhotoController>
[HttpGet]
public async Task<ActionResult<IEnumerable<Photo>>> Get([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
public async Task<ActionResult<IEnumerable<PhotoModel>>> 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/<PhotoController>/5
[HttpGet("{id}/{res}")]
public async Task<IActionResult> Get(Guid id, string res = "low")
[HttpGet("{res}/{id}")]
public async Task<IActionResult> 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/<PhotoController>
[HttpPost]
public async Task<IActionResult> 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/<PhotoController>/5
//[HttpPut("{id}")]
//public async Task<IActionResult> 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/<PhotoController>
[HttpPut]
public async Task<IActionResult> Put([FromBody] PhotoModel photo)
{
await _photoContext.Update(photo);
return NoContent();
}
// DELETE api/<PhotoController>/5
[HttpDelete("{id}")]
public async Task<IActionResult> 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();
}
}

View File

@@ -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<string>? tags, List<string>? 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<string>? Tags { get; set; }
public List<string>? 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<string>? tags, List<string>? 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;
}
}

View File

@@ -0,0 +1,104 @@
using back.Options;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace back.persistance.blob;
public class FileSystemImageStorageService(
IOptions<Databases> systemOptions,
IOptionsMonitor<DatabaseConfig> 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<Stream?> GetStreamAsync(string fileName)
{
var path = GetFullPath(fileName);
if (File.Exists(path))
{
if (cache.TryGetValue(path, out Stream? cachedStream))
{
return Task.FromResult<Stream?>(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<Stream?>(fileStream);
}
return Task.FromResult<Stream?>(null);
}
public async Task<byte[]?> 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);
}
}

View File

@@ -0,0 +1,11 @@
namespace back.persistance.blob;
public interface IBlobStorageService
{
Task SaveAsync(Stream blobStream, string fileName);
Task<Stream?> GetStreamAsync(string fileName);
Task<byte[]?> GetBytesAsync(string fileName);
Task DeleteAsync(string fileName);
Task UpdateAsync(Stream blobStream, string fileName);
}

Binary file not shown.

View File

View File

@@ -0,0 +1,6 @@
namespace back.services.ImageResizer;
public interface IImageResizer
{
Task<Stream> ResizeImage(IFormFile image, int v);
}

View File

@@ -0,0 +1,23 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace back.services.ImageResizer;
public sealed class ImageResizer : IImageResizer
{
public async Task<Stream> 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;
}
}

View File

@@ -0,0 +1,2 @@
nvs latest
node .\_fix-svg-stroke.js

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<ul>
@if (gallery.photos) { @for (item of gallery.photos; track $index) {
@if (gallery().photos) { @for (item of gallery().photos; track $index) {
<li>
<img [src]="item.lowResUrl" [alt]="item.title" />
</li>

View File

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

View File

@@ -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<homeGallery>(new homeGallery());
ngOnInit() {}
}

View File

@@ -26,15 +26,30 @@ export class paginator<T> {
private params: Record<string, any> = {};
private page: number = 1;
private totalItems: number = 0;
private totalItems: number = 20;
private isLoading: boolean = false;
private alreadyLoadedPages: Record<number, T[]> = {};
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<T[] | null> {
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<T[] | null> {
@@ -54,8 +69,10 @@ export class paginator<T> {
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 {