diff --git a/src/Portfolio.Api/App.razor b/src/Portfolio.Api/App.razor new file mode 100644 index 0000000..4124adc --- /dev/null +++ b/src/Portfolio.Api/App.razor @@ -0,0 +1,58 @@ +@using Microsoft.AspNetCore.Components.Web; +@using Microsoft.AspNetCore.Components.Routing + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Portfolio.Api/Controller/AboutController.cs b/src/Portfolio.Api/Controller/AboutController.cs new file mode 100644 index 0000000..8a8f2b4 --- /dev/null +++ b/src/Portfolio.Api/Controller/AboutController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Controller; + +[ApiController, Route("api/about")] +public class AboutController(IAboutRepository repository) : ControllerBase { + + [HttpGet] + public async Task GetAbout(CancellationToken ct) { + var about = await repository.GetAbout(ct); + return Ok(about); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Controller/ProjectController.cs b/src/Portfolio.Api/Controller/ProjectController.cs new file mode 100644 index 0000000..139228e --- /dev/null +++ b/src/Portfolio.Api/Controller/ProjectController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Controller; + +[ApiController, Route("api/projects")] +public class ProjectController(IProjectRepository repository) : ControllerBase { + + [HttpGet] + public async Task GetProjects(CancellationToken ct) { + var projects = await repository.GetProjects(ct); + return Ok(projects); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Controller/TechnologyController.cs b/src/Portfolio.Api/Controller/TechnologyController.cs new file mode 100644 index 0000000..40de333 --- /dev/null +++ b/src/Portfolio.Api/Controller/TechnologyController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Controller; + +[ApiController, Route("api/technologies")] +public class TechnologyController(ITechnologyRepository repository) : ControllerBase { + + [HttpGet] + public async Task GetTechnologies(CancellationToken ct) { + var technologies = await repository.GetTechnologies(ct); + return Ok(technologies); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Controller/TimelineController.cs b/src/Portfolio.Api/Controller/TimelineController.cs new file mode 100644 index 0000000..4a405ce --- /dev/null +++ b/src/Portfolio.Api/Controller/TimelineController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Controller; + +[ApiController, Route("api/timeline")] +public class TimelineController(ITimelineRepository repository) : ControllerBase { + + [HttpGet("{type}")] + public async Task GetTimeline(TimelineEntryType type, CancellationToken ct) { + var timeline = await repository.GetTimeline(type, ct); + return Ok(timeline); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/DatabaseContext.cs b/src/Portfolio.Api/DatabaseContext.cs new file mode 100644 index 0000000..aaa7b5c --- /dev/null +++ b/src/Portfolio.Api/DatabaseContext.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Portfolio.Shared.Models; + +namespace Portfolio.Api; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Projects { get; set; } + + public DbSet Languages { get; set; } + + public DbSet Technologies { get; set; } + + public DbSet Timeline { get; set; } + + public DbSet About { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity() + .HasMany(p => p.Languages) + .WithMany() + .UsingEntity("LanguageProject"); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Portfolio.Api.csproj b/src/Portfolio.Api/Portfolio.Api.csproj index 35db154..f7751ce 100644 --- a/src/Portfolio.Api/Portfolio.Api.csproj +++ b/src/Portfolio.Api/Portfolio.Api.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Portfolio.Api/Program.cs b/src/Portfolio.Api/Program.cs index e850aef..e093629 100644 --- a/src/Portfolio.Api/Program.cs +++ b/src/Portfolio.Api/Program.cs @@ -1,3 +1,10 @@ +using HopFrame.Core.Config; +using HopFrame.Web; +using Portfolio.Api; +using Portfolio.Api.Services; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. @@ -5,6 +12,57 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); builder.AddServiceDefaults(); +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); +builder.AddNpgsqlDbContext("data"); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); + +builder.Services.AddHopFrame(options => { + options.DisplayUserInfo(false); + options.AddDbContext(context => { + context.Table(table => { + var langConfig = table.InnerConfig.Properties + .Single(prop => prop.Name == nameof(Project.Languages)); + + langConfig + .GetType()! + .GetProperty(nameof(PropertyConfig.IsRelation))! + .SetValue(langConfig, true); + + langConfig + .GetType()! + .GetProperty(nameof(PropertyConfig.IsEnumerable))! + .SetValue(langConfig, true); + + langConfig + .GetType()! + .GetProperty(nameof(PropertyConfig.IsRequired))! + .SetValue(langConfig, true); + + table.Property(p => p.Languages) + .FormatEach((l, _) => l.Label) + .List(false); + + table.Property(p => p.Cover) + .List(false); + + table.Property(p => p.Description) + .List(false) + .IsTextArea(true); + + table.Property(p => p.SourceCode) + .List(false); + }); + }); +}); +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -12,7 +70,19 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } +await using (var scope = app.Services.CreateAsyncScope()) { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); +} + app.UseHttpsRedirection(); app.MapDefaultEndpoints(); +app.MapControllers(); + +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents() + .MapHopFramePages(); + app.Run(); diff --git a/src/Portfolio.Api/Services/AboutRepository.cs b/src/Portfolio.Api/Services/AboutRepository.cs new file mode 100644 index 0000000..c61a9ef --- /dev/null +++ b/src/Portfolio.Api/Services/AboutRepository.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Services; + +internal sealed class AboutRepository(DatabaseContext context) : IAboutRepository { + + public async Task GetAbout(CancellationToken ct) { + return await context.About + .SingleAsync(ct); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Services/ProjectRepository.cs b/src/Portfolio.Api/Services/ProjectRepository.cs new file mode 100644 index 0000000..836afd9 --- /dev/null +++ b/src/Portfolio.Api/Services/ProjectRepository.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Services; + +internal sealed class ProjectRepository(DatabaseContext context) : IProjectRepository { + + public async Task> GetProjects(CancellationToken ct) { + return await context.Projects + .Include(p => p.Languages) + .ToArrayAsync(ct); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Services/TechnologyRepository.cs b/src/Portfolio.Api/Services/TechnologyRepository.cs new file mode 100644 index 0000000..9f599ef --- /dev/null +++ b/src/Portfolio.Api/Services/TechnologyRepository.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Services; + +internal sealed class TechnologyRepository(DatabaseContext context) : ITechnologyRepository { + + public async Task> GetTechnologies(CancellationToken ct) { + return await context.Technologies.ToArrayAsync(ct); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Api/Services/TimelineRepository.cs b/src/Portfolio.Api/Services/TimelineRepository.cs new file mode 100644 index 0000000..2a8428c --- /dev/null +++ b/src/Portfolio.Api/Services/TimelineRepository.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Portfolio.Shared.Models; +using Portfolio.Shared.Services; + +namespace Portfolio.Api.Services; + +internal sealed class TimelineRepository(DatabaseContext context) : ITimelineRepository { + + public async Task> GetTimeline(TimelineEntryType type, CancellationToken ct) { + return await context.Timeline + .Where(entry => entry.Type == type) + .ToArrayAsync(ct); + } + +} \ No newline at end of file diff --git a/src/Portfolio.Host/Portfolio.Host.csproj b/src/Portfolio.Host/Portfolio.Host.csproj index 5f44a98..083deb5 100644 --- a/src/Portfolio.Host/Portfolio.Host.csproj +++ b/src/Portfolio.Host/Portfolio.Host.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Portfolio.Host/Program.cs b/src/Portfolio.Host/Program.cs index e976287..ae405b9 100644 --- a/src/Portfolio.Host/Program.cs +++ b/src/Portfolio.Host/Program.cs @@ -1,9 +1,17 @@ var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddProject("api"); +var postgres = builder.AddPostgres("postgres") + .WithDataVolume("portfolio-postgres"); + +var db = postgres.AddDatabase("data"); + +var api = builder.AddProject("api") + .WithReference(db) + .WaitFor(db); builder.AddProject("web") .WithReference(api) - .WaitFor(api); + .WaitFor(api) + .WithExternalHttpEndpoints(); builder.Build().Run(); \ No newline at end of file diff --git a/src/Portfolio.Shared/Models/About.cs b/src/Portfolio.Shared/Models/About.cs new file mode 100644 index 0000000..baaabe8 --- /dev/null +++ b/src/Portfolio.Shared/Models/About.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Portfolio.Shared.Models; + +public sealed class About { + + [Key] + public int Id { get; private set; } = 0; + + [MaxLength(5000)] + public required string AboutMe { get; set; } + + [MaxLength(5000)] + public required string Future { get; set; } + +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Models/Language.cs b/src/Portfolio.Shared/Models/Language.cs new file mode 100644 index 0000000..f6ee861 --- /dev/null +++ b/src/Portfolio.Shared/Models/Language.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Portfolio.Shared.Models; + +public sealed class Language { + + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; init; } + + [MaxLength(255)] + public required string Label { get; set; } + + [MaxLength(255)] + public required string Identifier { get; set; } + + [MaxLength(255)] + public string? Suffix { get; set; } + +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Models/Project.cs b/src/Portfolio.Shared/Models/Project.cs new file mode 100644 index 0000000..fab23ce --- /dev/null +++ b/src/Portfolio.Shared/Models/Project.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Portfolio.Shared.Models; + +public sealed class Project { + + [Key] + public Guid Id { get; init; } = Guid.CreateVersion7(); + + [MaxLength(255)] + public required string Cover { get; set; } + + [MaxLength(255)] + public required string Name { get; set; } + + [MaxLength(1000)] + public string? Description { get; set; } + + [MaxLength(255)] + public required string SourceCode { get; set; } + + public bool Featured { get; set; } + + public int OrderIndex { get; set; } + + public ProjectStatus Status { get; set; } = ProjectStatus.Finished; + + [ForeignKey("languages")] + public List Languages { get; init; } = new(); + + public DateTime Created { get; init; } = DateTime.Now; + +} + +public enum ProjectStatus : byte { + Finished = 0, + Canceled = 1, + Paused = 2, + Development = 3 +} diff --git a/src/Portfolio.Shared/Models/Technology.cs b/src/Portfolio.Shared/Models/Technology.cs new file mode 100644 index 0000000..edd44dd --- /dev/null +++ b/src/Portfolio.Shared/Models/Technology.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Portfolio.Shared.Models; + +public sealed class Technology { + + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; init; } + + [MaxLength(255)] + public required string Name { get; set; } + + public TechnologyLevel Level { get; set; } = TechnologyLevel.Beginner; + +} + +public enum TechnologyLevel : byte { + Beginner = 0, + Intermediate = 1, + Professional = 2 +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Models/TimelineEntry.cs b/src/Portfolio.Shared/Models/TimelineEntry.cs new file mode 100644 index 0000000..4c25ca1 --- /dev/null +++ b/src/Portfolio.Shared/Models/TimelineEntry.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Portfolio.Shared.Models; + +public sealed class TimelineEntry { + + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; init; } + + public DateOnly Date { get; init; } + + [MaxLength(1000)] + public required string Description { get; set; } + + public bool Featured { get; set; } + + public TimelineEntryType Type { get; init; } + +} + +public enum TimelineEntryType : byte { + Experience = 0, + Carrier = 1 +} diff --git a/src/Portfolio.Shared/Services/IAboutRepository.cs b/src/Portfolio.Shared/Services/IAboutRepository.cs new file mode 100644 index 0000000..8202a30 --- /dev/null +++ b/src/Portfolio.Shared/Services/IAboutRepository.cs @@ -0,0 +1,9 @@ +using Portfolio.Shared.Models; + +namespace Portfolio.Shared.Services; + +public interface IAboutRepository { + + Task GetAbout(CancellationToken ct); + +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Services/IProjectRepository.cs b/src/Portfolio.Shared/Services/IProjectRepository.cs new file mode 100644 index 0000000..11b17d1 --- /dev/null +++ b/src/Portfolio.Shared/Services/IProjectRepository.cs @@ -0,0 +1,9 @@ +using Portfolio.Shared.Models; + +namespace Portfolio.Shared.Services; + +public interface IProjectRepository { + + Task> GetProjects(CancellationToken ct); + +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Services/ITechnologyRepository.cs b/src/Portfolio.Shared/Services/ITechnologyRepository.cs new file mode 100644 index 0000000..fb34e75 --- /dev/null +++ b/src/Portfolio.Shared/Services/ITechnologyRepository.cs @@ -0,0 +1,9 @@ +using Portfolio.Shared.Models; + +namespace Portfolio.Shared.Services; + +public interface ITechnologyRepository { + + Task> GetTechnologies(CancellationToken ct); + +} \ No newline at end of file diff --git a/src/Portfolio.Shared/Services/ITimelineRepository.cs b/src/Portfolio.Shared/Services/ITimelineRepository.cs new file mode 100644 index 0000000..561f075 --- /dev/null +++ b/src/Portfolio.Shared/Services/ITimelineRepository.cs @@ -0,0 +1,9 @@ +using Portfolio.Shared.Models; + +namespace Portfolio.Shared.Services; + +public interface ITimelineRepository { + + Task> GetTimeline(TimelineEntryType type, CancellationToken ct); + +} \ No newline at end of file