diff --git a/angular.json b/angular.json
index d8a58d5..1c07f58 100644
--- a/angular.json
+++ b/angular.json
@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
- "packageManager": "npm"
+ "packageManager": "npm",
+ "analytics": false
},
"newProjectRoot": "projects",
"projects": {
diff --git a/src/app/app.html b/src/app/app.html
index 3e1c6ed..f5458a5 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -19,9 +19,7 @@
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index dab2150..07f5399 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -2,10 +2,14 @@ import { Routes } from '@angular/router';
import { Home } from '../pages/home/home';
import { About } from '../pages/about/about';
import { Contact } from '../pages/contact/contact';
+import { Projects } from '../pages/projects/projects';
+import { ProjectDetail } from '../pages/projects/project-detail/project-detail';
export const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: Home },
{ path: 'about', component: About },
{ path: 'contact', component: Contact },
+ { path: 'projects', component: Projects },
+ { path: 'projects/:projectName', component: ProjectDetail },
];
diff --git a/src/pages/home/home.css b/src/pages/home/home.css
index 913f04f..24b1a3e 100644
--- a/src/pages/home/home.css
+++ b/src/pages/home/home.css
@@ -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;
diff --git a/src/pages/home/home.html b/src/pages/home/home.html
index 2be3ee9..ee34f73 100644
--- a/src/pages/home/home.html
+++ b/src/pages/home/home.html
@@ -10,6 +10,25 @@
+
+
+
Featured Projects
+
Recent work across web platforms, APIs, and mobile apps.
+
+
+
+
Tech Stack
@@ -17,27 +36,10 @@
@for (skill of skills; track skill.name) {
-
- {{ skill.name }}
- {{ skill.category }} - {{ skill.level }}
-
- }
-
-
-
-
-
-
Featured Projects
-
Recent work across web platforms, APIs, and mobile apps.
-
-
- @for (project of projects; track project.title) {
-
- {{ project.title }}
- {{ project.description }}
- {{ project.stack.join(' - ') }}
- {{ project.impact }}
-
+
+ {{ skill.name }}
+ {{ skill.category }} - {{ skill.level }}
+
}
diff --git a/src/pages/home/home.spec.ts b/src/pages/home/home.spec.ts
index 3ddc79a..9d17aed 100644
--- a/src/pages/home/home.spec.ts
+++ b/src/pages/home/home.spec.ts
@@ -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');
});
});
diff --git a/src/pages/home/home.ts b/src/pages/home/home.ts
index df3a856..aaa22a3 100644
--- a/src/pages/home/home.ts
+++ b/src/pages/home/home.ts
@@ -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[] = [
{
diff --git a/src/pages/projects/project-data.ts b/src/pages/projects/project-data.ts
new file mode 100644
index 0000000..dbdff85
--- /dev/null
+++ b/src/pages/projects/project-data.ts
@@ -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);
+}
+
diff --git a/src/pages/projects/project-detail/project-detail.css b/src/pages/projects/project-detail/project-detail.css
new file mode 100644
index 0000000..cc15850
--- /dev/null
+++ b/src/pages/projects/project-detail/project-detail.css
@@ -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;
+ }
+}
+
diff --git a/src/pages/projects/project-detail/project-detail.html b/src/pages/projects/project-detail/project-detail.html
new file mode 100644
index 0000000..024620f
--- /dev/null
+++ b/src/pages/projects/project-detail/project-detail.html
@@ -0,0 +1,45 @@
+
+ @if (project; as project) {
+
+ Project
+ {{ project.title }}
+ {{ project.overview }}
+
+
+
+
+
+
+ Stack
+ {{ project.stack.join(' - ') }}
+
+
+
+ Impact
+ {{ project.impact }}
+
+
+
+
+
+
+ Highlights
+
+ @for (highlight of project.highlights; track highlight) {
+ - {{ highlight }}
+ }
+
+
+
+ } @else {
+
+ Project
+ Project not found
+ The requested project does not exist. Please pick one from the projects page.
+ Back to projects
+
+ }
+
+
diff --git a/src/pages/projects/project-detail/project-detail.spec.ts b/src/pages/projects/project-detail/project-detail.spec.ts
new file mode 100644
index 0000000..e270a74
--- /dev/null
+++ b/src/pages/projects/project-detail/project-detail.spec.ts
@@ -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> {
+ 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');
+ });
+});
diff --git a/src/pages/projects/project-detail/project-detail.ts b/src/pages/projects/project-detail/project-detail.ts
new file mode 100644
index 0000000..332bc6b
--- /dev/null
+++ b/src/pages/projects/project-detail/project-detail.ts
@@ -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);
+ }
+}
+
diff --git a/src/pages/projects/projects.css b/src/pages/projects/projects.css
new file mode 100644
index 0000000..bb11afd
--- /dev/null
+++ b/src/pages/projects/projects.css
@@ -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;
+ }
+}
+
diff --git a/src/pages/projects/projects.html b/src/pages/projects/projects.html
new file mode 100644
index 0000000..8029e81
--- /dev/null
+++ b/src/pages/projects/projects.html
@@ -0,0 +1,26 @@
+
+
+ Projects
+ Featured work across web, backend, and mobile.
+
+ A selection of projects that demonstrate my skills in building production-ready software
+ solutions across various platforms and technologies.
+
+
+
+
+
+
diff --git a/src/pages/projects/projects.spec.ts b/src/pages/projects/projects.spec.ts
new file mode 100644
index 0000000..0411082
--- /dev/null
+++ b/src/pages/projects/projects.spec.ts
@@ -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;
+
+ 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);
+ });
+});
+
diff --git a/src/pages/projects/projects.ts b/src/pages/projects/projects.ts
new file mode 100644
index 0000000..d06436a
--- /dev/null
+++ b/src/pages/projects/projects.ts
@@ -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;
+}
+