Add Projects page with detail view, removed navbar entries that only led to home and made featured projects links to the new site
All checks were successful
publish.yml / publish (push) Successful in 1m9s

This commit is contained in:
2026-04-15 22:14:45 +02:00
parent f0f07b4719
commit 4730d3592f
16 changed files with 508 additions and 58 deletions

View File

@@ -117,6 +117,28 @@ h2 {
gap: 1rem;
}
.project-link {
display: block;
color: inherit;
text-decoration: none;
}
.project-card {
height: 100%;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.project-link:hover .project-card,
.project-link:focus-visible .project-card {
border-color: rgba(88, 166, 255, 0.8);
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(31, 111, 235, 0.2);
}
.project-link:focus-visible {
outline: none;
}
.card h3 {
margin: 0;
font-size: 1.08rem;

View File

@@ -10,6 +10,25 @@
</div>
</header>
<section class="section" id="projects">
<div class="section-title-row">
<h2>Featured Projects</h2>
<p>Recent work across web platforms, APIs, and mobile apps.</p>
</div>
<div class="card-grid projects-grid">
@for (project of projects; track project.slug) {
<a class="project-link" [routerLink]="['/projects', project.slug]">
<app-card cardClass="project-card">
<h3>{{ project.title }}</h3>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
</a>
}
</div>
</section>
<section class="section" id="stack">
<div class="section-title-row">
<h2>Tech Stack</h2>
@@ -17,27 +36,10 @@
</div>
<div class="card-grid">
@for (skill of skills; track skill.name) {
<app-card cardClass="skill-card">
<h3>{{ skill.name }}</h3>
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
</app-card>
}
</div>
</section>
<section class="section" id="projects">
<div class="section-title-row">
<h2>Featured Projects</h2>
<p>Recent work across web platforms, APIs, and mobile apps.</p>
</div>
<div class="card-grid projects-grid">
@for (project of projects; track project.title) {
<app-card cardClass="project-card">
<h3>{{ project.title }}</h3>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
<app-card cardClass="skill-card">
<h3>{{ skill.name }}</h3>
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
</app-card>
}
</div>
</section>

View File

@@ -30,8 +30,11 @@ describe('Home', () => {
expect(compiled.querySelector('.hero-cta a[routerlink="/about"]')).toBeTruthy();
});
it('should render all project cards', () => {
it('should render all project cards as links', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelectorAll('.project-card').length).toBe(component.projects.length);
const links = compiled.querySelectorAll('#projects .project-link');
expect(links.length).toBe(component.projects.length);
expect(links[0]?.getAttribute('href')).toContain('/projects/tasktimer');
});
});

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card';
import { PROJECTS, Project } from '../projects/project-data';
interface Skill {
name: string;
@@ -8,13 +9,6 @@ interface Skill {
level: string;
}
interface Project {
title: string;
description: string;
stack: string[];
impact: string;
}
interface TimelineItem {
role: string;
company: string;
@@ -51,30 +45,7 @@ export class Home {
{ name: 'Flutter', category: 'Mobile', level: 'Advanced' },
];
readonly projects: Project[] = [
{
title: 'Tasktimer',
description:
'Jira time-tracking app with custom reporting and connectivity, build for mobile and web.',
stack: ['Ionic', 'React', 'Spring Boot', 'Oracle', 'Web', 'Android', 'iOS'],
impact:
'Led backend and architectural work: implemented Spring Boot backend, database access for Oracle with reporting capabilities, including reliable Jira connectivity.',
},
{
title: 'Synopsis Platform Core',
description: 'Main Synopsis Platform codebase (backend + frontend pieces).',
stack: ['C#', 'TypeScript', 'HTML', 'Docker', 'Web'],
impact:
'Contributed via issues, code and investigations (plugin-loader, microfrontend research), influencing platform direction and stability.',
},
{
title: 'Website SV Hofkirchen (Chess Club)',
description: 'Club management web app for the SV Hofkirchen chess club.',
stack: ['C#', '.NET', 'Blazor', 'SQLite', 'Web'],
impact:
'Designed the technical core across backend, database, and Blazor UI: implemented backend services, and partially built the Blazor frontend',
},
];
readonly projects: Project[] = PROJECTS;
readonly timeline: TimelineItem[] = [
{

View File

@@ -0,0 +1,63 @@
export interface Project {
slug: string;
title: string;
description: string;
stack: string[];
impact: string;
overview: string;
highlights: string[];
}
export const PROJECTS: Project[] = [
{
slug: 'tasktimer',
title: 'Tasktimer',
description:
'Jira time-tracking app with custom reporting and connectivity, built for mobile and web.',
stack: ['Ionic', 'React', 'Spring Boot', 'Oracle', 'Web', 'Android', 'iOS'],
impact:
'Led backend and architecture work for reporting and Jira synchronization reliability.',
overview:
'Tasktimer helps teams capture and report time across Jira projects while keeping data synchronized and audit-friendly.',
highlights: [
'Implemented a Spring Boot API with Oracle-backed reporting endpoints.',
'Designed resilient Jira connectivity to keep remote data in sync.',
'Supported cross-platform delivery for web and mobile clients.',
],
},
{
slug: 'synopsis-platform-core',
title: 'Synopsis Platform Core',
description: 'Main Synopsis Platform codebase spanning backend and frontend modules.',
stack: ['C#', 'TypeScript', 'HTML', 'Docker', 'Web'],
impact:
'Contributed investigations and implementation work that improved platform stability and direction.',
overview:
'The platform core powers shared capabilities used by multiple product teams, with a focus on maintainable architecture.',
highlights: [
'Delivered issue-driven improvements in plugin loading behavior.',
'Contributed to microfrontend architecture research and experiments.',
'Supported production hardening with iterative bug-fixing and code cleanup.',
],
},
{
slug: 'sv-hofkirchen-website',
title: 'Website SV Hofkirchen (Chess Club)',
description: 'Club management web app for the SV Hofkirchen chess club.',
stack: ['C#', '.NET', 'Blazor', 'SQLite', 'Web'],
impact:
'Built core backend services and key parts of the Blazor UI to support club operations.',
overview:
'A practical web app for memberships, event coordination, and day-to-day administration for a local chess club.',
highlights: [
'Implemented backend services and database flows with SQLite.',
'Designed and built major parts of the Blazor frontend.',
'Shaped the project architecture to stay lightweight and maintainable.',
],
},
];
export function getProjectBySlug(projectSlug: string): Project | undefined {
return PROJECTS.find((project) => project.slug === projectSlug);
}

View File

@@ -0,0 +1,102 @@
.page-wrap {
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.section {
margin-bottom: 3rem;
}
.hero {
padding: 2.5rem;
border: 1px solid rgba(110, 118, 129, 0.28);
border-radius: 24px;
background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.2), rgba(13, 17, 23, 0.85));
}
.eyebrow {
width: fit-content;
margin: 0 0 1rem;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px;
color: #9ecbff;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1,
h2,
p,
ul {
margin: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 1.1;
}
.lede {
max-width: 70ch;
margin-top: 1rem;
color: #c9d1d9;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
display: inline-block;
padding: 0.72rem 1.1rem;
border: 1px solid #3d444d;
border-radius: 10px;
color: #e6edf3;
background: rgba(33, 38, 45, 0.55);
font-weight: 600;
text-decoration: none;
transition: transform 120ms ease, border-color 120ms ease;
}
.btn:hover,
.btn:focus-visible {
transform: translateY(-2px);
border-color: #58a6ff;
outline: none;
}
.detail-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.meta {
color: #8b949e;
font-size: 0.95rem;
}
ul {
padding-left: 1.2rem;
}
li + li {
margin-top: 0.45rem;
}
@media (max-width: 760px) {
.page-wrap {
padding-top: 0.5rem;
}
.hero {
padding: 2rem 1.2rem;
}
}

View File

@@ -0,0 +1,45 @@
<div class="page-wrap">
@if (project; as project) {
<section class="hero section">
<p class="eyebrow">Project</p>
<h1>{{ project.title }}</h1>
<p class="lede">{{ project.overview }}</p>
<div class="hero-actions">
<a class="btn btn-ghost" routerLink="/projects">Back to projects</a>
</div>
</section>
<section class="section">
<div class="detail-grid">
<app-card>
<h2>Stack</h2>
<p class="meta">{{ project.stack.join(' - ') }}</p>
</app-card>
<app-card>
<h2>Impact</h2>
<p>{{ project.impact }}</p>
</app-card>
</div>
</section>
<section class="section">
<app-card>
<h2>Highlights</h2>
<ul>
@for (highlight of project.highlights; track highlight) {
<li>{{ highlight }}</li>
}
</ul>
</app-card>
</section>
} @else {
<section class="hero section">
<p class="eyebrow">Project</p>
<h1>Project not found</h1>
<p class="lede">The requested project does not exist. Please pick one from the projects page.</p>
<a class="btn btn-ghost" routerLink="/projects">Back to projects</a>
</section>
}
</div>

View File

@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ProjectDetail } from './project-detail';
describe('ProjectDetail', () => {
async function createComponent(projectName: string): Promise<ComponentFixture<ProjectDetail>> {
await TestBed.configureTestingModule({
imports: [ProjectDetail],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ projectName }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectDetail);
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
afterEach(() => {
TestBed.resetTestingModule();
});
it('should render project details for known project', async () => {
const fixture = await createComponent('tasktimer');
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Tasktimer');
expect(compiled.querySelectorAll('li').length).toBeGreaterThan(0);
});
it('should show not found state for unknown project', async () => {
const fixture = await createComponent('unknown-project');
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Project not found');
});
});

View File

@@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { CardComponent } from '../../../components/card/card';
import { getProjectBySlug, Project } from '../project-data';
@Component({
selector: 'app-project-detail',
imports: [RouterLink, CardComponent],
templateUrl: './project-detail.html',
styleUrl: './project-detail.css',
})
export class ProjectDetail {
private readonly route = inject(ActivatedRoute);
get project(): Project | undefined {
const projectName = this.route.snapshot.paramMap.get('projectName') ?? '';
return getProjectBySlug(projectName);
}
}

View File

@@ -0,0 +1,98 @@
.page-wrap {
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.section {
margin-bottom: 3rem;
}
.hero {
padding: 2.5rem;
border: 1px solid rgba(110, 118, 129, 0.28);
border-radius: 24px;
background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.2), rgba(13, 17, 23, 0.85));
}
.eyebrow {
width: fit-content;
margin: 0 0 1rem;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px;
color: #9ecbff;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 1.1;
}
.lede {
max-width: 65ch;
margin-top: 1rem;
color: #c9d1d9;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.project-link {
display: block;
color: inherit;
text-decoration: none;
}
.project-card {
height: 100%;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.project-link:hover .project-card,
.project-link:focus-visible .project-card {
border-color: rgba(88, 166, 255, 0.8);
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(31, 111, 235, 0.2);
}
.project-link:focus-visible {
outline: none;
}
.project-card p {
margin-top: 0.75rem;
}
.meta {
color: #8b949e;
font-size: 0.92rem;
}
.impact {
color: #9ecbff;
font-weight: 500;
}
@media (max-width: 760px) {
.page-wrap {
padding-top: 0.5rem;
}
.hero {
padding: 2rem 1.2rem;
}
}

View File

@@ -0,0 +1,26 @@
<div class="page-wrap">
<section class="hero section">
<p class="eyebrow">Projects</p>
<h1>Featured work across web, backend, and mobile.</h1>
<p class="lede">
A selection of projects that demonstrate my skills in building production-ready software
solutions across various platforms and technologies.
</p>
</section>
<section class="section">
<div class="card-grid projects-grid">
@for (project of projects; track project.slug) {
<a class="project-link" [routerLink]="['/projects', project.slug]">
<app-card cardClass="project-card">
<h2>{{ project.title }}</h2>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
</a>
}
</div>
</section>
</div>

View File

@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Projects } from './projects';
describe('Projects', () => {
let component: Projects;
let fixture: ComponentFixture<Projects>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Projects],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(Projects);
component = fixture.componentInstance;
await fixture.whenStable();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render project cards and links', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Featured work');
expect(compiled.querySelectorAll('.project-card').length).toBe(component.projects.length);
expect(compiled.querySelectorAll('.project-link').length).toBe(component.projects.length);
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card';
import { PROJECTS, Project } from './project-data';
@Component({
selector: 'app-projects',
imports: [RouterLink, CardComponent],
templateUrl: './projects.html',
styleUrl: './projects.css',
})
export class Projects {
readonly projects: Project[] = PROJECTS;
}