pimped the site a little but and fixed impress
This commit is contained in:
1 parent
f543a5e2f3
commit
888fb6289d
14 files changed
+1502
-1162
No files matched your search
@@ -41,6 +41,10 @@ export class AssetsConstants {
|
||||
'/assets/projects/diploma/6.jpg'
|
||||
];
|
||||
|
||||
static readonly CLAUDE_BOX_IMAGES = [
|
||||
'/assets/projects/claude-in-a-box/1.svg'
|
||||
];
|
||||
|
||||
static readonly TRIBBLE_IMAGES = [
|
||||
'/assets/projects/tribble-the-homeserver/1.png',
|
||||
'/assets/projects/tribble-the-homeserver/2.png',
|
||||
|
||||
@@ -7,5 +7,8 @@
|
||||
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
|
||||
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small>
|
||||
<br />
|
||||
<small>{{ `APP.AI_NOTICE` | translate }}</small>
|
||||
<small>
|
||||
{{ 'APP.AI_NOTICE' | translate }}
|
||||
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
||||
</small>
|
||||
</footer>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import {TopbarComponent} from '../topbar/topbar.component';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
||||
@@ -8,7 +8,7 @@ import {SeoService} from '../../service/seo.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||
imports: [RouterLink, RouterOutlet, TopbarComponent, TranslatePipe, ParticleBackgroundComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card>
|
||||
<mat-card class="card-gradient-bar">
|
||||
<div class="flex flex-wrap gap-fluid-lg p-fluid-md items-start">
|
||||
<div class="flex-[1_1_min(100%,425px)] max-w-full flex justify-center">
|
||||
<img class="block w-full h-auto max-w-[425px] rounded-xl shadow-[0_6px_24px_rgba(0,0,0,0.25)] object-cover"
|
||||
@@ -13,7 +13,7 @@
|
||||
{{ 'ABOUT.LEAD' | translate }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
<div class="flex flex-col gap-1 mb-4">
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">work</mat-icon>
|
||||
<span>{{ 'ABOUT.ROLE' | translate }}</span>
|
||||
@@ -24,18 +24,26 @@
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">email</mat-icon>
|
||||
<a href="" (click)="SharedFunctions.openMail($event)">
|
||||
<button type="button" class="link-button" data-testid="contact-mail"
|
||||
(click)="SharedFunctions.openMail($event)">
|
||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon>data_object</mat-icon>
|
||||
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||
<a href="{{UrlConstants.CODEBERG}}" target="_blank" rel="noopener">Codeberg</a>
|
||||
<span>·</span>
|
||||
<mat-icon svgIcon="linkedin"></mat-icon>
|
||||
<mat-icon svgIcon="linkedin" aria-hidden="true"></mat-icon>
|
||||
<a href="{{UrlConstants.LINKED_IN}}" target="_blank" rel="noopener">LinkedIn</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-fluid-sm">
|
||||
<a mat-flat-button [routerLink]="['/projects']">
|
||||
<mat-icon aria-hidden="true">grid_view</mat-icon>
|
||||
{{ 'ABOUT.VIEW_PROJECTS' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card>
|
||||
@@ -43,72 +51,62 @@
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.SKILLS' | translate }}</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,250px),1fr))] gap-fluid-sm ml-1 mb-2">
|
||||
@for (group of skillGroups; track group.titleKey) {
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.BACKEND_ARCH' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Backend and Architecture">
|
||||
@for (s of skillsArchitecture; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.INFRA_CLOUD' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Infrastructure and Cloud">
|
||||
@for (s of skillsCore; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ 'ABOUT.SECTION.SIM_ALGO' | translate }}</h3>
|
||||
<mat-chip-set aria-label="Simulation and Algorithms">
|
||||
@for (s of skillsEngineering; track s) {
|
||||
<mat-chip>{{ s | translate }}</mat-chip>
|
||||
<h3 class="my-1 text-[0.95rem] opacity-85">{{ group.titleKey | translate }}</h3>
|
||||
<mat-chip-set [attr.aria-label]="group.titleKey | translate">
|
||||
@for (skillKey of group.skillKeys; track skillKey) {
|
||||
<mat-chip>{{ skillKey | translate }}</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-2 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.EXPERIENCE' | translate }}</h2>
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
@for (entry of xpKeys; track entry.key) {
|
||||
<div>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-x-3">
|
||||
<div class="row-span-2 col-start-1 flex items-center">
|
||||
<img src="{{entry.logo}}" alt="" class="w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="row-start-1 col-start-2 flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row-start-2 col-start-2 mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.COMPANY') | translate }}
|
||||
</div>
|
||||
|
||||
<div class="relative ml-2">
|
||||
<span class="absolute left-5 top-2 bottom-2 w-px bg-current opacity-15" aria-hidden="true"></span>
|
||||
@for (entry of visibleExperience(); track entry.key; let i = $index) {
|
||||
<div class="relative pl-14 pb-fluid-sm">
|
||||
@if (isFirstRoleAtCompany(i)) {
|
||||
<img src="{{entry.logo}}" alt="" aria-hidden="true"
|
||||
class="absolute left-0 top-0 w-10 h-10 object-contain opacity-90 rounded-[10%] bg-logo-bg" />
|
||||
<div class="font-semibold opacity-90 text-[clamp(0.9rem,2.2vw,1.05rem)] leading-10" data-testid="company">
|
||||
{{ (entry.key + '.COMPANY') | translate }}
|
||||
</div>
|
||||
@if (!entry.no_highlights) {
|
||||
<div class="mt-[0.4rem] ml-[clamp(0.25rem,1vw,0.75rem)] pl-[clamp(0.8rem,2vw,1.2rem)]">
|
||||
<ul>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P1' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P2' | translate }}</li>
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ entry.key + '.HIGHLIGHTS.P3' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="absolute left-5 top-[0.6rem] -translate-x-1/2 w-2 h-2 rounded-full bg-current opacity-30"
|
||||
aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="role">{{ (entry.key + '.ROLE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.TIME') | translate }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="mt-[0.4rem]">
|
||||
@for (highlight of highlightKeys(entry); track highlight) {
|
||||
<li class="my-1 text-[clamp(0.9rem,2vw,1rem)]">{{ highlight | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (isLastRoleAtCompany(i) && !$last) {
|
||||
<mat-divider class="mt-fluid-sm!"></mat-divider>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if(entry.key !== xpKeys.at(xpKeys.length-1)?.key)
|
||||
{
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<button mat-button type="button" class="mt-1 ml-2" data-testid="toggle-experience" (click)="toggleEarlierExperience()"
|
||||
[attr.aria-expanded]="showEarlierExperience()">
|
||||
<mat-icon aria-hidden="true">{{ showEarlierExperience() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ (showEarlierExperience() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_XP') | translate }}
|
||||
</button>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-[clamp(5px,2vw,15px)]">
|
||||
<h2 class="mt-1 ml-1 text-[clamp(1.2rem,4vw,1.8rem)]">{{ 'ABOUT.SECTION.PROJECTS' | translate }}</h2>
|
||||
|
||||
@@ -124,7 +122,7 @@
|
||||
@if (entry.externalLink) {
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" href="{{entry.externalLink}}" target="_blank" rel="noopener noreferrer">
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">open_in_new</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">open_in_new</mat-icon>
|
||||
{{ (entry.key + '.LINK_EXTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -132,7 +130,7 @@
|
||||
<div class="mt-[0.1rem] opacity-85">
|
||||
<a class="inline-flex items-center gap-[0.35rem] leading-none" [routerLink]="['/projects']" [queryParams]="{ project: entry.identifier }"
|
||||
rel="noopener noreferrer">
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!">link</mat-icon>
|
||||
<mat-icon class="text-[18px]! w-[18px]! h-[18px]!" aria-hidden="true">link</mat-icon>
|
||||
{{ (entry.key + '.LINK_INTERNAL') | translate }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -145,8 +143,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(entry.key !== projectKeys.at(projectKeys.length-1)?.key)
|
||||
{
|
||||
@if (!$last) {
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
}
|
||||
@@ -158,21 +155,25 @@
|
||||
|
||||
<div class="ml-1 grid gap-fluid-sm">
|
||||
<div>
|
||||
@for (entry of educationKeys; track entry.key) {
|
||||
@for (key of visibleEducationKeys(); track key) {
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]">{{ (entry.key + '.WHERE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (entry.key + '.WHEN') | translate }}</span>
|
||||
<strong class="text-[clamp(0.95rem,2.5vw,1.1rem)]" data-testid="education-where">{{ (key + '.WHERE') | translate }}</strong>
|
||||
<span class="opacity-75 text-[clamp(0.85rem,2vw,0.95rem)]">{{ (key + '.WHEN') | translate }}</span>
|
||||
</div>
|
||||
<div class="mt-[0.1rem] opacity-85 text-[clamp(0.85rem,2vw,1rem)]">
|
||||
{{ (entry.key + '.WHAT') | translate }}
|
||||
{{ (key + '.WHAT') | translate }}
|
||||
</div>
|
||||
|
||||
@if(entry.key !== educationKeys.at(educationKeys.length-1)?.key)
|
||||
{
|
||||
@if (!$last) {
|
||||
<mat-divider class="my-2!"></mat-divider>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button mat-button type="button" class="mt-1" data-testid="toggle-education" (click)="toggleEarlierEducation()"
|
||||
[attr.aria-expanded]="showEarlierEducation()">
|
||||
<mat-icon aria-hidden="true">{{ showEarlierEducation() ? 'expand_less' : 'expand_more' }}</mat-icon>
|
||||
{{ (showEarlierEducation() ? 'ABOUT.SECTION.SHOW_LESS' : 'ABOUT.SECTION.SHOW_EARLIER_EDU') | translate }}
|
||||
</button>
|
||||
</mat-card>
|
||||
</section>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { AboutComponent } from './about.component';
|
||||
|
||||
/**
|
||||
* No translations are loaded, so the translate pipe echoes the key back. That is
|
||||
* exactly what these tests want: they assert which key is rendered where, which
|
||||
* is what the mismatched skill headings got wrong. Expected values are written
|
||||
* out literally rather than read off the component, so a change to the component
|
||||
* cannot silently move the assertion with it.
|
||||
*/
|
||||
describe('AboutComponent', () => {
|
||||
let fixture: ComponentFixture<AboutComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AboutComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AboutComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
function textOf(selector: string): string[] {
|
||||
return fixture.debugElement
|
||||
.queryAll(By.css(selector))
|
||||
.map(element => element.nativeElement.textContent.trim());
|
||||
}
|
||||
|
||||
function click(testId: string): void {
|
||||
fixture.debugElement
|
||||
.query(By.css(`[data-testid="${testId}"]`))
|
||||
.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function skillGroupTexts(): { heading: string; chips: string[] }[] {
|
||||
return fixture.debugElement.queryAll(By.css('mat-chip-set')).map(chipSet => {
|
||||
const group = chipSet.parent!;
|
||||
const heading = group.query(By.css('h3')).nativeElement.textContent.trim();
|
||||
const chips = group
|
||||
.queryAll(By.css('mat-chip'))
|
||||
.map(chip => chip.nativeElement.textContent.trim());
|
||||
return { heading, chips };
|
||||
});
|
||||
}
|
||||
|
||||
describe('skills', () => {
|
||||
it('renders the skill groups in the documented order', () => {
|
||||
expect(skillGroupTexts().map(group => group.heading)).toEqual([
|
||||
'ABOUT.SECTION.LANGUAGES',
|
||||
'ABOUT.SECTION.ARCH_CLOUD',
|
||||
'ABOUT.SECTION.SIM_ALGO',
|
||||
'ABOUT.SECTION.AI_ENG',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists languages under the languages heading, not under architecture', () => {
|
||||
const languages = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.LANGUAGES');
|
||||
|
||||
expect(languages).toBeDefined();
|
||||
expect(languages!.chips).toContain('ABOUT.SKILLS.JAVA');
|
||||
expect(languages!.chips).not.toContain('ABOUT.TOOLS.DOCKER');
|
||||
});
|
||||
|
||||
it('lists infrastructure tools under the architecture heading', () => {
|
||||
const architecture = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.ARCH_CLOUD');
|
||||
|
||||
expect(architecture).toBeDefined();
|
||||
expect(architecture!.chips).toContain('ABOUT.TOOLS.DOCKER');
|
||||
expect(architecture!.chips).not.toContain('ABOUT.SKILLS.JAVA');
|
||||
});
|
||||
|
||||
it('advertises AI-assisted engineering as its own group', () => {
|
||||
const ai = skillGroupTexts().find(group => group.heading === 'ABOUT.SECTION.AI_ENG');
|
||||
|
||||
expect(ai).toBeDefined();
|
||||
expect(ai!.chips).toContain('ABOUT.SKILLS.AI_HARNESS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('experience', () => {
|
||||
it('hides the pre-2010 positions until they are requested', () => {
|
||||
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY9.ROLE');
|
||||
expect(textOf('[data-testid="role"]')).not.toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||
});
|
||||
|
||||
it('reveals the earlier positions when the toggle button is clicked', () => {
|
||||
click('toggle-experience');
|
||||
|
||||
expect(textOf('[data-testid="role"]')).toContain('ABOUT.XP.COMPANY0.ROLE');
|
||||
});
|
||||
|
||||
it('reports the expanded state of the experience toggle to assistive technology', () => {
|
||||
const toggle = fixture.debugElement.query(By.css('[data-testid="toggle-experience"]')).nativeElement;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
click('toggle-experience');
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('draws the employer header once per run of consecutive roles', () => {
|
||||
const companies = textOf('[data-testid="company"]');
|
||||
|
||||
// COMPANY5/4/3 are three consecutive Assyst roles and share one header.
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY5.COMPANY');
|
||||
expect(companies).not.toContain('ABOUT.XP.COMPANY4.COMPANY');
|
||||
expect(companies).not.toContain('ABOUT.XP.COMPANY3.COMPANY');
|
||||
});
|
||||
|
||||
it('treats the two ColorDigital spells as separate blocks so the timeline stays chronological', () => {
|
||||
const companies = textOf('[data-testid="company"]');
|
||||
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY9.COMPANY');
|
||||
expect(companies).toContain('ABOUT.XP.COMPANY7.COMPANY');
|
||||
});
|
||||
|
||||
it('keeps the employer header at the seam when the earlier roles are appended', () => {
|
||||
click('toggle-experience');
|
||||
|
||||
// COMPANY3 (Assyst) and COMPANY2 (TH Bingen) meet at the seam, so the
|
||||
// first earlier role must still get its own header.
|
||||
expect(textOf('[data-testid="company"]')).toContain('ABOUT.XP.COMPANY2.COMPANY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('education', () => {
|
||||
it('shows only the post-school entries until the toggle button is clicked', () => {
|
||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
|
||||
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||
|
||||
click('toggle-education');
|
||||
|
||||
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('call to action', () => {
|
||||
it('offers no download link, so nothing can point at a missing file', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the mail contact as a button rather than an empty link', () => {
|
||||
const emptyLinks = fixture.debugElement
|
||||
.queryAll(By.css('a'))
|
||||
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
||||
|
||||
expect(emptyLinks.length).toBe(0);
|
||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the mail button in the same font as the links it sits next to', () => {
|
||||
const mailButton = fixture.debugElement.query(By.css('[data-testid="contact-mail"]')).nativeElement;
|
||||
const siblingLink = fixture.debugElement
|
||||
.queryAll(By.css('a'))
|
||||
.find(anchor => anchor.nativeElement.textContent.trim() === 'Codeberg')!.nativeElement;
|
||||
|
||||
const buttonStyle = getComputedStyle(mailButton);
|
||||
const linkStyle = getComputedStyle(siblingLink);
|
||||
|
||||
expect(buttonStyle.fontSize).toBe(linkStyle.fontSize);
|
||||
expect(buttonStyle.fontFamily).toBe(linkStyle.fontFamily);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component} from '@angular/core';
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
@@ -11,6 +11,18 @@ import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
|
||||
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
||||
interface ExperienceEntry {
|
||||
key: string;
|
||||
company: string;
|
||||
logo: string;
|
||||
highlightCount: number;
|
||||
}
|
||||
|
||||
interface SkillGroup {
|
||||
titleKey: string;
|
||||
skillKeys: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
@@ -29,51 +41,83 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
})
|
||||
export class AboutComponent {
|
||||
|
||||
xpKeys = [
|
||||
/** Roles older than the current decade are hidden until the reader asks for them. */
|
||||
readonly showEarlierExperience = signal(false);
|
||||
readonly showEarlierEducation = signal(false);
|
||||
|
||||
readonly recentExperience: ExperienceEntry[] = [
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY9',
|
||||
company: 'colordigital',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
no_highlights: false
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY8',
|
||||
logo: AssetsConstants.TERAPORT_LOGO
|
||||
company: 'teraport',
|
||||
logo: AssetsConstants.TERAPORT_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY7',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
||||
company: 'colordigital-earlier',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY6',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO
|
||||
company: 'colordigital-earlier',
|
||||
logo: AssetsConstants.COLORDIGITAL_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY5',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY4',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY3',
|
||||
logo: AssetsConstants.ASSYST_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY2',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY1',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY0',
|
||||
logo: AssetsConstants.CHAMAELEON_LOGO
|
||||
company: 'assyst',
|
||||
logo: AssetsConstants.ASSYST_LOGO,
|
||||
highlightCount: 3
|
||||
}
|
||||
];
|
||||
|
||||
projectKeys = [
|
||||
readonly earlierExperience: ExperienceEntry[] = [
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY2',
|
||||
company: 'th-bingen',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY1',
|
||||
company: 'th-bingen',
|
||||
logo: AssetsConstants.TH_BINGEN_LOGO,
|
||||
highlightCount: 3
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.XP.COMPANY0',
|
||||
company: 'chamaeleon',
|
||||
logo: AssetsConstants.CHAMAELEON_LOGO,
|
||||
highlightCount: 3
|
||||
}
|
||||
];
|
||||
|
||||
readonly projectKeys = [
|
||||
{
|
||||
key: 'ABOUT.PROJECT.CLAUDE_BOX',
|
||||
externalLink: '',
|
||||
internalLink: 'projects',
|
||||
identifier: 'claude-in-a-box',
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.PROJECT.P2',
|
||||
externalLink: 'https://andreas-dahm.eu/',
|
||||
@@ -106,59 +150,114 @@ export class AboutComponent {
|
||||
}
|
||||
];
|
||||
|
||||
educationKeys = [
|
||||
readonly recentEducationKeys = [
|
||||
'ABOUT.EDUCATION.E6',
|
||||
'ABOUT.EDUCATION.E5',
|
||||
'ABOUT.EDUCATION.E4'
|
||||
];
|
||||
|
||||
readonly earlierEducationKeys = [
|
||||
'ABOUT.EDUCATION.E3',
|
||||
'ABOUT.EDUCATION.E2',
|
||||
'ABOUT.EDUCATION.E1',
|
||||
'ABOUT.EDUCATION.E0'
|
||||
];
|
||||
|
||||
/**
|
||||
* Heading and chips travel together so the two can no longer drift apart —
|
||||
* they used to be declared in separate places and had ended up mismatched.
|
||||
*/
|
||||
readonly skillGroups: SkillGroup[] = [
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E6'
|
||||
titleKey: 'ABOUT.SECTION.LANGUAGES',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.JAVA',
|
||||
'ABOUT.SKILLS.SPRING',
|
||||
'ABOUT.SKILLS.TYPESCRIPT',
|
||||
'ABOUT.SKILLS.ANGULAR',
|
||||
'ABOUT.SKILLS.CSHARP',
|
||||
'ABOUT.SKILLS.PYTHON',
|
||||
'ABOUT.SKILLS.VUE',
|
||||
'ABOUT.SKILLS.REACT'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E5'
|
||||
titleKey: 'ABOUT.SECTION.ARCH_CLOUD',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||
'ABOUT.TOOLS.DOCKER',
|
||||
'ABOUT.TOOLS.K8S',
|
||||
'ABOUT.TOOLS.POSTGRES',
|
||||
'ABOUT.TOOLS.MONGO',
|
||||
'ABOUT.TOOLS.GITLAB',
|
||||
'ABOUT.TOOLS.JENKINS'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E4'
|
||||
titleKey: 'ABOUT.SECTION.SIM_ALGO',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.ENG_ALGO',
|
||||
'ABOUT.SKILLS.ENG_SIM',
|
||||
'ABOUT.SKILLS.ENG_GPU',
|
||||
'ABOUT.SKILLS.ENG_PERF',
|
||||
'ABOUT.SKILLS.UNITY'
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E3'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E2'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E1'
|
||||
},
|
||||
{
|
||||
key: 'ABOUT.EDUCATION.E0'
|
||||
titleKey: 'ABOUT.SECTION.AI_ENG',
|
||||
skillKeys: [
|
||||
'ABOUT.SKILLS.AI_AGENTS',
|
||||
'ABOUT.SKILLS.AI_MCP',
|
||||
'ABOUT.SKILLS.AI_CONTEXT',
|
||||
'ABOUT.SKILLS.AI_HARNESS',
|
||||
'ABOUT.SKILLS.AI_GATES'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
skillsCore = [
|
||||
'ABOUT.SKILLS.JAVA',
|
||||
'ABOUT.SKILLS.SPRING',
|
||||
'ABOUT.SKILLS.ANGULAR',
|
||||
'ABOUT.SKILLS.VUE',
|
||||
'ABOUT.SKILLS.REACT',
|
||||
'ABOUT.SKILLS.TYPESCRIPT',
|
||||
'ABOUT.SKILLS.CSHARP',
|
||||
'ABOUT.SKILLS.PYTHON'
|
||||
];
|
||||
|
||||
skillsArchitecture = [
|
||||
'ABOUT.SKILLS.ARCH_MICROSERVICES',
|
||||
'ABOUT.SKILLS.ARCH_CLOUD',
|
||||
'ABOUT.TOOLS.DOCKER',
|
||||
'ABOUT.TOOLS.K8S',
|
||||
'ABOUT.TOOLS.JENKINS',
|
||||
'ABOUT.TOOLS.POSTGRES',
|
||||
'ABOUT.TOOLS.MONGO',
|
||||
'ABOUT.TOOLS.GITLAB'
|
||||
];
|
||||
readonly visibleExperience = computed(() => {
|
||||
if (this.showEarlierExperience()) {
|
||||
return [...this.recentExperience, ...this.earlierExperience];
|
||||
}
|
||||
return this.recentExperience;
|
||||
});
|
||||
|
||||
skillsEngineering = [
|
||||
'ABOUT.SKILLS.ENG_ALGO',
|
||||
'ABOUT.SKILLS.ENG_SIM',
|
||||
'ABOUT.SKILLS.ENG_GPU',
|
||||
'ABOUT.SKILLS.UNITY',
|
||||
'ABOUT.SKILLS.ENG_PERF'
|
||||
];
|
||||
readonly visibleEducationKeys = computed(() => {
|
||||
if (this.showEarlierEducation()) {
|
||||
return [...this.recentEducationKeys, ...this.earlierEducationKeys];
|
||||
}
|
||||
return this.recentEducationKeys;
|
||||
});
|
||||
|
||||
toggleEarlierExperience(): void {
|
||||
this.showEarlierExperience.update(shown => !shown);
|
||||
}
|
||||
|
||||
toggleEarlierEducation(): void {
|
||||
this.showEarlierEducation.update(shown => !shown);
|
||||
}
|
||||
|
||||
/** The employer header is drawn once per run of consecutive roles at the same company. */
|
||||
isFirstRoleAtCompany(index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
const entries = this.visibleExperience();
|
||||
return entries[index - 1].company !== entries[index].company;
|
||||
}
|
||||
|
||||
isLastRoleAtCompany(index: number): boolean {
|
||||
const entries = this.visibleExperience();
|
||||
if (index === entries.length - 1) {
|
||||
return true;
|
||||
}
|
||||
return entries[index + 1].company !== entries[index].company;
|
||||
}
|
||||
|
||||
highlightKeys(entry: ExperienceEntry): string[] {
|
||||
return Array.from({ length: entry.highlightCount }, (_, i) => `${entry.key}.HIGHLIGHTS.P${i + 1}`);
|
||||
}
|
||||
|
||||
protected readonly UrlConstants = UrlConstants;
|
||||
protected readonly AssetsConstants = AssetsConstants;
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<p>
|
||||
Andreas Dahm<br />
|
||||
Bgm.-Germeier-Str. 2<br />
|
||||
85586 Poing<br />
|
||||
Hauptstraße 37A<br />
|
||||
85614 Kirchseeon<br />
|
||||
{{ 'IMPRINT.COUNTRY' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,33 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
private queryParamSub: Subscription | undefined;
|
||||
|
||||
allProjects: Projects[] = [
|
||||
{
|
||||
identifier: "claude-in-a-box",
|
||||
title: 'PROJECTS.CLAUDE_BOX.TITLE',
|
||||
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
|
||||
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
|
||||
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
|
||||
icon: 'smart_toy',
|
||||
assets: '',
|
||||
links: [],
|
||||
bulletPoints: [
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_1',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_2',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_3',
|
||||
'PROJECTS.CLAUDE_BOX.BULLET_4',
|
||||
],
|
||||
challenges: [
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_1',
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_2',
|
||||
'PROJECTS.CLAUDE_BOX.CHALLENGE_3',
|
||||
],
|
||||
learnings: [
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_1',
|
||||
'PROJECTS.CLAUDE_BOX.LEARNING_2',
|
||||
],
|
||||
isFeatured: false,
|
||||
technologies: ['Claude Code', 'MCP', 'Docker', 'Bash', 'Node.js']
|
||||
},
|
||||
{
|
||||
identifier: "playground",
|
||||
title: 'PROJECTS.PLAYGROUND.TITLE',
|
||||
|
||||
Reference in new issue
Block a user