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