Merge branch 'feature/technologyPage' into 'dev'

Resolve "Technology Page"

Closes #5

See merge request leon.hoppe/Portfolio!5
This commit was merged in pull request #21.
This commit is contained in:
2025-01-23 17:51:37 +00:00
18 changed files with 359 additions and 39 deletions

View File

@@ -7,8 +7,6 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options) : DbCont
public DbSet<Project> Projects { get; set; }
public DbSet<Language> Languages { get; set; }
public DbSet<Technology> Technologies { get; set; }
public DbSet<TimelineEntry> Timeline { get; set; }

View File

@@ -45,7 +45,7 @@ builder.Services.AddHopFrame(options => {
.SetValue(langConfig, true);
table.Property(p => p.Languages)
.FormatEach<Language>((l, _) => l.Label)
.FormatEach<Technology>((l, _) => l.Name)
.List(false);
table.Property(p => p.Cover)

View File

@@ -1,20 +0,0 @@
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; }
}

View File

@@ -26,8 +26,8 @@ public sealed class Project {
public ProjectStatus Status { get; set; } = ProjectStatus.Finished;
[ForeignKey("languages")]
public List<Language> Languages { get; init; } = new();
[ForeignKey("technologies")]
public List<Technology> Languages { get; init; } = new();
public DateTime Created { get; init; } = DateTime.Now;

View File

@@ -11,12 +11,26 @@ public sealed class Technology {
[MaxLength(255)]
public required string Name { get; set; }
[MaxLength(255)]
public required string Identifier { get; set; }
[MaxLength(255)]
public string? Suffix { get; set; }
public TechnologyLevel Level { get; set; } = TechnologyLevel.Beginner;
public TechnologyType Type { get; set; } = TechnologyType.Language;
}
public enum TechnologyLevel : byte {
public enum TechnologyLevel {
Beginner = 0,
Intermediate = 1,
Professional = 2
}
public enum TechnologyType {
Language = 0,
Framework = 1,
Additional = 2
}

View File

@@ -7,6 +7,7 @@
<base href="/"/>
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["Portfolio.Web.styles.css"]"/>
<script src="@Assets["scroll-handler.js"]"></script>
<ImportMap/>
<link rel="icon" href="favicon.ico"/>
<HeadOutlet/>

View File

@@ -1,6 +1,6 @@
@using Portfolio.Shared.Models
<div class="border">
<div class="border" style="@("--index: " + Index)">
<div class="shell">
<img src="@Project.Cover" alt="project-cover">
<h3>@Project.Name</h3>
@@ -32,6 +32,9 @@
[Parameter]
public required Project Project { get; set; }
[Parameter]
public required int Index { get; set; }
private string GetStatusName() {
return Project.Status switch {
ProjectStatus.Finished => "Fertig",

View File

@@ -6,6 +6,19 @@
border-radius: 30px;
overflow: hidden;
box-shadow: 0 0 40px -10px var(--primary);
opacity: 0;
animation: animate-in 200ms forwards;
animation-delay: calc(var(--index) * 200ms);
}
@keyframes animate-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.shell {

View File

@@ -0,0 +1,40 @@
@rendermode InteractiveServer
@using Portfolio.Shared.Models
<div class="technology">
<div class="tech-header">
<h2 class="tech-name">@Technology.Name</h2>
<span class="tech-level">@GetTechnologyLevelName()</span>
</div>
<div class="@("tech-progress level-" + (int)Technology.Level)" @ref="_element"></div>
</div>
@inject IJSRuntime Runtime
@code {
[Parameter]
public required Technology Technology { get; set; }
private ElementReference _element;
public string GetTechnologyLevelName() {
switch (Technology.Level) {
case TechnologyLevel.Beginner:
return "Anfänger";
case TechnologyLevel.Intermediate:
return "Erweitert";
case TechnologyLevel.Professional:
return "Fortgeschritten";
default:
return "Normal";
}
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!firstRender) return;
await Runtime.InvokeVoidAsync("observeElement", _element);
}
}

View File

@@ -0,0 +1,55 @@
.technology {
margin-bottom: 2rem;
.tech-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
.tech-name {
margin: 0;
font-size: 20px;
font-weight: normal;
}
.tech-level {
align-self: flex-end;
color: var(--desc-color);
}
}
.tech-progress {
height: 10px;
border-radius: 5px;
background: var(--gradient);
box-shadow: 0 3px 10px 1px var(--primary-muted);
transition: width 200ms ease-out;
width: 0;
&.level-1 {
--width: 33%;
}
&.level-2 {
--width: 66%;
}
&.level-3 {
--width: 100%;
}
&.in-view {
animation: slider-in 500ms forwards ease-out;
}
}
}
@keyframes slider-in {
from {
width: 0;
}
to {
width: var(--width);
}
}

View File

@@ -9,6 +9,7 @@ main {
width: 100%;
position: sticky;
top: 0;
z-index: 100;
}
.content {

View File

@@ -6,7 +6,6 @@
background-color: var(--background);
border-bottom: 1px solid var(--border-color);
padding-inline: 0.5rem;
z-index: 100;
}
.links {

View File

@@ -3,11 +3,13 @@
@using Portfolio.Shared.Services
@using Portfolio.Web.Components.Components
<PageTitle>Projekte</PageTitle>
<h2>Alle Projekte</h2>
<div class="project-container">
@foreach (var project in _projects) {
<ProjectView Project="project" />
@foreach (var (index, project) in _projects.Index()) {
<ProjectView Project="project" Index="index" />
}
</div>

View File

@@ -4,10 +4,3 @@
gap: 100px;
justify-content: space-evenly;
}
h2 {
margin-top: 3rem;
margin-bottom: 5rem;
font-size: 2rem;
font-weight: 600;
}

View File

@@ -0,0 +1,100 @@
@page "/technologies"
@rendermode InteractiveServer
@using System.Collections.Immutable
@using Portfolio.Shared.Models
@using Portfolio.Shared.Services
@using Portfolio.Web.Components.Components
<PageTitle>Technologien</PageTitle>
<section class="home-section" id="tech-projects">
<div class="artwork">
<div class="circle big-circle"></div>
<div class="circle small-circle"></div>
<div class="circle image"></div>
</div>
<h2>Technologien in Projekten</h2>
<div class="chart">
<div class="chart-container"><canvas id="chart"></canvas></div>
</div>
</section>
<section class="home-section" id="languages">
<h2>Programmiersprachen</h2>
<div class="technologies-wrapper">
@foreach (var tech in _technologies.Where(t => t.Type == TechnologyType.Language)) {
<TechnologyView Technology="tech" />
}
</div>
</section>
<section class="home-section" id="frameworks">
<h2>Frameworks</h2>
<div class="technologies-wrapper">
@foreach (var tech in _technologies.Where(t => t.Type == TechnologyType.Framework)) {
<TechnologyView Technology="tech" />
}
</div>
</section>
<section class="home-section" id="additional">
<h2>Zusätzliche Fähigkeiten</h2>
<div id="skills-wrapper">
@foreach (var skill in _technologies.Where(t => t.Type == TechnologyType.Additional)) {
<div class="skill">
<div class="dot"></div>
<h3>@skill.Name</h3>
</div>
}
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
function displayChart(labels, data) {
if (Chart === undefined) return;
const element = document.querySelector('#chart');
Chart.defaults.color = "#FFFFFF";
new Chart(element, {
type: "doughnut",
data: {
labels: labels,
datasets: [{
label: "Projekte",
data: data
}]
}
});
}
</script>
@inherits CancellableComponent
@inject ITechnologyRepository TechnologyRepository
@inject IProjectRepository ProjectRepository
@inject IJSRuntime Runtime
@code {
private IEnumerable<Technology> _technologies = [];
protected override async Task OnInitializedAsync() {
_technologies = await TechnologyRepository.GetTechnologies(TokenSource.Token);
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
var projects = await ProjectRepository.GetProjects(TokenSource.Token);
var data = projects
.SelectMany(p => p.Languages)
.CountBy(l => l.Name)
.ToList();
await Runtime.InvokeVoidAsync("displayChart",
data.Select(c => c.Key),
data.Select(c => c.Value));
}
}
}

View File

@@ -0,0 +1,52 @@
#tech-projects {
margin-top: 50px;
position: relative;
.chart {
display: flex;
margin-top: 30px;
.chart-container {
width: 500px;
height: 500px;
}
}
.artwork {
top: 40px;
}
}
.home-section h2 {
margin-block: 5rem 2rem;
}
#additional {
#skills-wrapper {
display: flex;
justify-content: space-evenly;
gap: 20px;
margin-top: 30px;
flex-wrap: wrap;
.skill {
display: flex;
gap: 10px;
align-items: center;
.dot {
width: 15px;
height: 15px;
background: var(--gradient);
box-shadow: 0 3px 10px 1px var(--primary-muted);
border-radius: 50%;
}
h3 {
font-weight: normal;
margin: 0;
font-size: 20px;
}
}
}
}

View File

@@ -1,6 +1,7 @@
:root {
--primary: #8e5bd2;
--primary: rgb(142, 91, 210);
--secondary: #11a8bd;
--primary-muted: rgba(142, 91, 210, 0.4);
--gradient: linear-gradient(90deg, var(--primary), var(--secondary));
--gradient-angled: linear-gradient(135deg, var(--primary), var(--secondary));
@@ -30,3 +31,61 @@ html, body {
padding: 0;
height: 100%;
}
h2 {
margin-top: 3rem;
margin-bottom: 5rem;
font-size: 2rem;
font-weight: 600;
}
.artwork {
position: absolute;
left: 55%;
top: 19vh;
z-index: 0;
.circle {
position: absolute;
aspect-ratio: 1 / 1;
z-index: -1;
background: var(--gradient-angled);
border-radius: 50%;
&:after {
content: '';
position: absolute;
inset: 1px;
background-color: var(--background);
border-radius: 50%;
}
}
.big-circle {
left: 0;
top: 0;
width: 400px;
}
.small-circle {
top: 100px;
left: 350px;
width: 150px;
&:after {
content: none;
}
}
.image {
top: -50px;
left: 170px;
width: 250px;
&:after {
background-image: url("/favicon.ico");
background-size: 112%;
background-position: -15px -15px;
}
}
}

View File

@@ -0,0 +1,10 @@
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
entry.target.classList.add("in-view");
});
});
function observeElement(element) {
observer?.observe(element);
}