From 4730d3592f39071bb0f48cd70d77da46ae44e6ef Mon Sep 17 00:00:00 2001 From: Tim Kainz Date: Wed, 15 Apr 2026 22:14:45 +0200 Subject: [PATCH] Add Projects page with detail view, removed navbar entries that only led to home and made featured projects links to the new site --- angular.json | 3 +- src/app/app.html | 4 +- src/app/app.routes.ts | 4 + src/pages/home/home.css | 22 ++++ src/pages/home/home.html | 44 ++++---- src/pages/home/home.spec.ts | 7 +- src/pages/home/home.ts | 33 +----- src/pages/projects/project-data.ts | 63 +++++++++++ .../project-detail/project-detail.css | 102 ++++++++++++++++++ .../project-detail/project-detail.html | 45 ++++++++ .../project-detail/project-detail.spec.ts | 47 ++++++++ .../projects/project-detail/project-detail.ts | 20 ++++ src/pages/projects/projects.css | 98 +++++++++++++++++ src/pages/projects/projects.html | 26 +++++ src/pages/projects/projects.spec.ts | 33 ++++++ src/pages/projects/projects.ts | 15 +++ 16 files changed, 508 insertions(+), 58 deletions(-) create mode 100644 src/pages/projects/project-data.ts create mode 100644 src/pages/projects/project-detail/project-detail.css create mode 100644 src/pages/projects/project-detail/project-detail.html create mode 100644 src/pages/projects/project-detail/project-detail.spec.ts create mode 100644 src/pages/projects/project-detail/project-detail.ts create mode 100644 src/pages/projects/projects.css create mode 100644 src/pages/projects/projects.html create mode 100644 src/pages/projects/projects.spec.ts create mode 100644 src/pages/projects/projects.ts 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; +} +