Compare commits
3 Commits
6c1572bf3c
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
| 763bf435c9 | |||
| cfc7580a8f | |||
| d954361724 |
@@ -1,8 +1,8 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -17,6 +17,6 @@ export const appConfig: ApplicationConfig = {
|
||||
}),
|
||||
),
|
||||
provideClientHydration(withEventReplay()),
|
||||
{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT },
|
||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="site-actions cluster">
|
||||
<app-command-palette></app-command-palette>
|
||||
<a
|
||||
class="language-switch"
|
||||
[routerLink]="navigation.alternateLocaleLink()"
|
||||
|
||||
@@ -2,18 +2,25 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, type Routes } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from './app.routes';
|
||||
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { type AppRouteData } from './core/routing/app-route-data';
|
||||
|
||||
function flattenRoutes(tree: Routes, prefix = ''): Array<{ path: string; data?: AppRouteData }> {
|
||||
const entries: Array<{ path: string; data?: AppRouteData }> = [];
|
||||
function flattenRoutes(
|
||||
tree: Routes,
|
||||
prefix = '',
|
||||
): Array<{ path: string; data?: AppRouteData; title?: string }> {
|
||||
const entries: Array<{ path: string; data?: AppRouteData; title?: string }> = [];
|
||||
|
||||
for (const route of tree) {
|
||||
const segment = route.path ?? '';
|
||||
const path = [prefix, segment].filter((part) => part.length > 0).join('/');
|
||||
entries.push({ path, data: route.data as AppRouteData | undefined });
|
||||
entries.push({
|
||||
path,
|
||||
data: route.data as AppRouteData | undefined,
|
||||
title: typeof route.title === 'string' ? route.title : undefined,
|
||||
});
|
||||
|
||||
if (route.children) {
|
||||
entries.push(...flattenRoutes(route.children, path));
|
||||
@@ -39,9 +46,9 @@ describe('app routes', () => {
|
||||
expect(germanWildcard?.data).toEqual({ routeId: 'notFound', locale: 'de' });
|
||||
});
|
||||
|
||||
it('navigates to the English projects placeholder', async () => {
|
||||
it('navigates to the English projects page', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
@@ -49,7 +56,24 @@ describe('app routes', () => {
|
||||
await harness.navigateByUrl('/en/projects');
|
||||
|
||||
expect(locale.locale()).toBe('en');
|
||||
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toContain('Projects');
|
||||
expect(harness.routeNativeElement?.textContent).toContain('This page is being built.');
|
||||
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toContain(
|
||||
SITE_CONTENT_DATA.en.pages.projects.hero.headline,
|
||||
);
|
||||
expect(harness.routeNativeElement?.textContent).toContain(
|
||||
SITE_CONTENT_DATA.en.cases.innofocus.client,
|
||||
);
|
||||
});
|
||||
|
||||
it('attaches the final page title for every locale route', () => {
|
||||
const flattened = flattenRoutes(routes);
|
||||
|
||||
for (const entry of flattened) {
|
||||
if (!entry.data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expected = SITE_CONTENT_DATA[entry.data.locale].pages[entry.data.routeId].title;
|
||||
expect(entry.title, entry.path).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Type } from '@angular/core';
|
||||
import { type Routes } from '@angular/router';
|
||||
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { localeResolver } from './core/i18n/locale.resolver';
|
||||
import { type AppLocale } from './core/i18n/locale';
|
||||
import { type AppRouteData } from './core/routing/app-route-data';
|
||||
@@ -39,14 +39,14 @@ function routeData(routeId: RouteId, locale: AppLocale): AppRouteData {
|
||||
|
||||
function localeRoutes(locale: AppLocale): Routes {
|
||||
const segments = ROUTE_SEGMENTS[locale];
|
||||
const pages = PLACEHOLDER_CONTENT[locale].pages;
|
||||
const pages = SITE_CONTENT_DATA[locale].pages;
|
||||
|
||||
return ROUTE_IDS.filter((routeId) => routeId !== 'notFound')
|
||||
.map((routeId) => ({
|
||||
path: segments[routeId],
|
||||
loadComponent: PAGE_LOADERS[routeId],
|
||||
data: routeData(routeId, locale),
|
||||
title: pages[routeId]?.title,
|
||||
title: pages[routeId].title,
|
||||
resolve: { locale: localeResolver },
|
||||
}))
|
||||
.concat([
|
||||
@@ -54,7 +54,7 @@ function localeRoutes(locale: AppLocale): Routes {
|
||||
path: '**',
|
||||
loadComponent: PAGE_LOADERS.notFound,
|
||||
data: routeData('notFound', locale),
|
||||
title: pages.notFound?.title,
|
||||
title: pages.notFound.title,
|
||||
resolve: { locale: localeResolver },
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -29,10 +29,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.site-actions app-command-palette {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-identity,
|
||||
.site-nav a,
|
||||
.site-actions a,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { routes } from './app.routes';
|
||||
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -11,7 +11,7 @@ describe('App', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@ import { SITE_CONFIG } from './core/content/site-config';
|
||||
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { NavigationService } from './core/navigation/navigation.service';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, CommandPalette],
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
|
||||
@@ -1,281 +1,53 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DotBackground } from './dot-background';
|
||||
|
||||
function mockContext(): CanvasRenderingContext2D {
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
|
||||
return {
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: matchesQuery(query),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
describe('DotBackground', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, 'matchMedia');
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
});
|
||||
let component: DotBackground;
|
||||
let fixture: ComponentFixture<DotBackground>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
|
||||
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
fixture = TestBed.createComponent(DotBackground);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
});
|
||||
|
||||
it('should create', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const fixture = await createFixture();
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not throw when destroyed after browser initialization', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
const context = {
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
};
|
||||
|
||||
const fixture = await createFixture();
|
||||
expect(() => fixture.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(scheduled).toEqual([]);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
|
||||
expect(() => fixture.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('cancels the scheduled frame and removes every listener on destroy', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
const windowRemove = vi.spyOn(window, 'removeEventListener');
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
const documentRemove = vi.spyOn(document, 'removeEventListener');
|
||||
|
||||
const fixture = await createFixture();
|
||||
|
||||
expect(raf).toHaveBeenCalled();
|
||||
|
||||
const windowAdds = windowAdd.mock.calls.filter((call) =>
|
||||
['resize', 'mousemove', 'click'].includes(String(call[0])),
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
context as unknown as CanvasRenderingContext2D,
|
||||
);
|
||||
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
|
||||
const animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
|
||||
|
||||
expect(windowAdds.length).toBeGreaterThan(0);
|
||||
expect(documentAdds.length).toBeGreaterThan(0);
|
||||
const initializedFixture = TestBed.createComponent(DotBackground);
|
||||
initializedFixture.detectChanges();
|
||||
await initializedFixture.whenStable();
|
||||
|
||||
fixture.destroy();
|
||||
expect(() => initializedFixture.destroy()).not.toThrow();
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
for (const [type, handler] of windowAdds) {
|
||||
expect(windowRemove).toHaveBeenCalledWith(type, handler);
|
||||
}
|
||||
|
||||
for (const [type, handler] of documentAdds) {
|
||||
expect(documentRemove).toHaveBeenCalledWith(type, handler);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws a single static frame under reduced motion and skips pointer listeners', async () => {
|
||||
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(context.clearRect).toHaveBeenCalled();
|
||||
expect(scheduled).toEqual([]);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('registers no pointer listeners for a coarse pointer', async () => {
|
||||
mockMatchMedia((query) => query.includes('pointer: coarse'));
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
await createFixture();
|
||||
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
|
||||
});
|
||||
|
||||
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
|
||||
await createFixture();
|
||||
expect(raf).toHaveBeenCalled();
|
||||
raf.mockClear();
|
||||
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(raf).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts with a bounded initial dot count on a large desktop viewport', async () => {
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560);
|
||||
const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
|
||||
try {
|
||||
const fixture = await createFixture();
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
const arcCalls = vi.mocked(context.arc).mock.calls.length;
|
||||
expect(arcCalls).toBeGreaterThanOrEqual(6);
|
||||
expect(arcCalls).toBeLessThanOrEqual(24);
|
||||
fixture.destroy();
|
||||
} finally {
|
||||
widthStub.mockRestore();
|
||||
heightStub.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('redraws the static frame after resize under reduced motion without starting a loop', async () => {
|
||||
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
vi.mocked(context.clearRect).mockClear();
|
||||
vi.mocked(context.arc).mockClear();
|
||||
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
const resizeFrames = scheduled.splice(0);
|
||||
for (const callback of resizeFrames) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(context.clearRect).toHaveBeenCalled();
|
||||
expect(context.arc).toHaveBeenCalled();
|
||||
expect(scheduled).toEqual([]);
|
||||
fixture.destroy();
|
||||
animationFrameSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
isBrowserPlatform,
|
||||
prefersCoarsePointer,
|
||||
prefersReducedMotion,
|
||||
} from '../../core/platform/browser';
|
||||
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
|
||||
import { Dot } from '../../models/dot';
|
||||
|
||||
@Component({
|
||||
@@ -20,33 +15,21 @@ import { Dot } from '../../models/dot';
|
||||
imports: [],
|
||||
templateUrl: './dot-background.html',
|
||||
styleUrl: './dot-background.scss',
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
})
|
||||
export class DotBackground {
|
||||
export class DotBackground implements OnDestroy {
|
||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly coarsePointer = prefersCoarsePointer();
|
||||
private readonly reducedMotion = prefersReducedMotion();
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
|
||||
private ctx: CanvasRenderingContext2D | undefined;
|
||||
private dots: Dot[] = [];
|
||||
private mouse = { x: -1000, y: -1000 };
|
||||
private animationId = 0;
|
||||
private resizeFrameId = 0;
|
||||
private loopActive = false;
|
||||
private tornDown = false;
|
||||
private readonly teardowns: Array<() => void> = [];
|
||||
private initialized = false;
|
||||
|
||||
private readonly INITIAL_DOT_COUNT_MIN = 6;
|
||||
private readonly INITIAL_DOT_COUNT_MAX = 24;
|
||||
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
|
||||
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
|
||||
private readonly INIT_DOT_COUNT = 12;
|
||||
private readonly MAX_DOT_COUNT = 100;
|
||||
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
||||
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
||||
@@ -56,34 +39,27 @@ export class DotBackground {
|
||||
private ballSpawnNextColor = 0;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
|
||||
afterNextRender(() => {
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
|
||||
private view(): Window | null {
|
||||
return this.document.defaultView;
|
||||
ngOnDestroy() {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimationFrame(this.animationId);
|
||||
|
||||
if (this.isBrowser) {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
window.removeEventListener('click', this.onMouseClick);
|
||||
}
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
if (this.tornDown || !this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.view();
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
private init() {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
try {
|
||||
@@ -100,113 +76,21 @@ export class DotBackground {
|
||||
this.resize();
|
||||
this.initDots();
|
||||
|
||||
this.listen(view, 'resize', this.onResize);
|
||||
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
||||
window.addEventListener('resize', this.resize);
|
||||
window.addEventListener('mousemove', this.onMouseMove);
|
||||
window.addEventListener('click', this.onMouseClick);
|
||||
|
||||
if (!this.reducedMotion && !this.coarsePointer) {
|
||||
this.listen(view, 'mousemove', this.onMouseMove);
|
||||
this.listen(view, 'click', this.onMouseClick);
|
||||
}
|
||||
|
||||
if (this.reducedMotion) {
|
||||
this.drawFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
this.initialized = true;
|
||||
this.ngZone.runOutsideAngular(() => this.animate());
|
||||
}
|
||||
|
||||
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
||||
target.addEventListener(type, handler);
|
||||
this.teardowns.push(() => target.removeEventListener(type, handler));
|
||||
}
|
||||
private resize = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
private requestFrame(callback: FrameRequestCallback): number {
|
||||
const view = this.view();
|
||||
return view ? view.requestAnimationFrame(callback) : 0;
|
||||
}
|
||||
|
||||
private cancelFrame(id: number): void {
|
||||
const view = this.view();
|
||||
|
||||
if (!view || id === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
private startLoop(): void {
|
||||
if (this.loopActive || this.reducedMotion || this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loopActive = true;
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
|
||||
private stopLoop(): void {
|
||||
this.loopActive = false;
|
||||
this.cancelFrame(this.animationId);
|
||||
this.animationId = 0;
|
||||
}
|
||||
|
||||
private scheduleAnimate(): void {
|
||||
this.animationId = this.requestFrame(this.animate);
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
if (this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tornDown = true;
|
||||
this.stopLoop();
|
||||
this.cancelFrame(this.resizeFrameId);
|
||||
this.resizeFrameId = 0;
|
||||
|
||||
for (const dispose of this.teardowns) {
|
||||
dispose();
|
||||
}
|
||||
|
||||
this.teardowns.length = 0;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
if (this.resizeFrameId !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeFrameId = this.requestFrame(() => {
|
||||
this.resizeFrameId = 0;
|
||||
this.resize();
|
||||
});
|
||||
};
|
||||
|
||||
private onVisibilityChange = (): void => {
|
||||
if (this.document.hidden) {
|
||||
this.stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.reducedMotion) {
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
}
|
||||
};
|
||||
|
||||
private resize = (): void => {
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = view.innerWidth;
|
||||
const height = view.innerHeight;
|
||||
|
||||
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
|
||||
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
|
||||
const dx = Math.abs(width - canvas.width) / width;
|
||||
const dy = Math.abs(height - canvas.height) / height;
|
||||
|
||||
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
||||
canvas.width = width;
|
||||
@@ -216,44 +100,21 @@ export class DotBackground {
|
||||
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
|
||||
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
|
||||
}
|
||||
|
||||
if (this.reducedMotion && this.ctx) {
|
||||
this.drawFrame();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseMove = (event: Event): void => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
|
||||
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
|
||||
private onMouseMove = (e: MouseEvent) => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
|
||||
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
|
||||
};
|
||||
|
||||
private onMouseClick = (): void => {
|
||||
private onMouseClick = () => {
|
||||
const dot = this.spawnDot();
|
||||
dot.x = this.mouse.x;
|
||||
dot.y = this.mouse.y;
|
||||
};
|
||||
|
||||
private targetDotCount(width: number, height: number): number {
|
||||
const maxInitial = this.coarsePointer
|
||||
? this.INITIAL_DOT_COUNT_MAX_COARSE
|
||||
: this.INITIAL_DOT_COUNT_MAX;
|
||||
const area = Math.max(0, width) * Math.max(0, height);
|
||||
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
|
||||
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
|
||||
}
|
||||
|
||||
private spawnDot(): Dot {
|
||||
const dotId = this.ballSpawnId++;
|
||||
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||
@@ -279,7 +140,7 @@ export class DotBackground {
|
||||
return dot;
|
||||
}
|
||||
|
||||
private populateDot(dot: Dot): void {
|
||||
private populateDot(dot: Dot) {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
|
||||
dot.x = Math.random() * width;
|
||||
@@ -290,29 +151,17 @@ export class DotBackground {
|
||||
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
||||
}
|
||||
|
||||
private initDots(): void {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
const count = this.targetDotCount(width, height);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
private initDots() {
|
||||
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
||||
this.spawnDot();
|
||||
}
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
this.animationId = 0;
|
||||
this.drawFrame();
|
||||
|
||||
if (this.loopActive && !this.tornDown) {
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
};
|
||||
|
||||
private drawFrame(): void {
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
private animate = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const ctx = this.ctx;
|
||||
|
||||
if (!canvas || !ctx) {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,5 +213,7 @@ export class DotBackground {
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="grid">
|
||||
@for (cat of categories; track cat.category) {
|
||||
@for (cat of categories(); track cat.category) {
|
||||
<app-skill-card
|
||||
[title]="cat.title"
|
||||
[category]="cat.category"
|
||||
|
||||
@@ -1,114 +1,125 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { SkillCategory } from '../../../../models/skill-category';
|
||||
import { SkillCard } from '../skill-card/skill-card';
|
||||
|
||||
const BASE_CATEGORIES: readonly SkillCategory[] = [
|
||||
{
|
||||
title: 'Programming',
|
||||
category: 'programming',
|
||||
gridArea: 'prog',
|
||||
skills: [
|
||||
{ name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' },
|
||||
{
|
||||
name: 'JavaScript',
|
||||
icon: 'javascript',
|
||||
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript',
|
||||
},
|
||||
{ name: 'Angular', icon: 'angular', url: 'https://angular.dev/' },
|
||||
{ name: 'Java', icon: 'java', url: 'https://www.java.com/' },
|
||||
{ name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' },
|
||||
{ name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' },
|
||||
{ name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' },
|
||||
{ name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' },
|
||||
{ name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Databases',
|
||||
category: 'db',
|
||||
gridArea: 'db',
|
||||
skills: [
|
||||
{
|
||||
name: 'SQL Server',
|
||||
icon: 'microsoftsqlserver',
|
||||
url: 'https://www.microsoft.com/en-us/sql-server',
|
||||
},
|
||||
{ name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' },
|
||||
{ name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' },
|
||||
{ name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DevOps',
|
||||
category: 'devops',
|
||||
gridArea: 'devops',
|
||||
skills: [
|
||||
{ name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' },
|
||||
{ name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' },
|
||||
{ name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' },
|
||||
{
|
||||
name: 'Azure DevOps',
|
||||
icon: 'devops',
|
||||
url: 'https://azure.microsoft.com/en-us/products/devops',
|
||||
},
|
||||
{ name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' },
|
||||
{ name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' },
|
||||
{ name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operating Systems',
|
||||
category: 'os',
|
||||
gridArea: 'os',
|
||||
skills: [
|
||||
{ name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' },
|
||||
{ name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' },
|
||||
{ name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' },
|
||||
{ name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Infrastructure as Code',
|
||||
category: 'iac',
|
||||
gridArea: 'iac',
|
||||
skills: [
|
||||
{ name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' },
|
||||
{ name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Hypervisors',
|
||||
category: 'hyperviser',
|
||||
gridArea: 'hyper',
|
||||
skills: [
|
||||
{ name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' },
|
||||
{
|
||||
name: 'Hyper-V',
|
||||
icon: 'hyperv',
|
||||
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
category: 'tools',
|
||||
gridArea: 'tools',
|
||||
skills: [
|
||||
{ name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' },
|
||||
{ name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' },
|
||||
{
|
||||
name: 'Microsoft Office',
|
||||
icon: 'office',
|
||||
url: 'https://www.microsoft.com/microsoft-365',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-skills-grid',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SkillCard],
|
||||
templateUrl: './skills-grid.html',
|
||||
styleUrl: './skills-grid.scss',
|
||||
})
|
||||
export class SkillsGrid {
|
||||
categories: SkillCategory[] = [
|
||||
{
|
||||
title: 'Programming',
|
||||
category: 'programming',
|
||||
gridArea: 'prog',
|
||||
skills: [
|
||||
{ name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' },
|
||||
{
|
||||
name: 'JavaScript',
|
||||
icon: 'javascript',
|
||||
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript',
|
||||
},
|
||||
{ name: 'Angular', icon: 'angular', url: 'https://angular.dev/' },
|
||||
{ name: 'Java', icon: 'java', url: 'https://www.java.com/' },
|
||||
{ name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' },
|
||||
{ name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' },
|
||||
{ name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' },
|
||||
{ name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' },
|
||||
{ name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Databases',
|
||||
category: 'db',
|
||||
gridArea: 'db',
|
||||
skills: [
|
||||
{
|
||||
name: 'SQL Server',
|
||||
icon: 'microsoftsqlserver',
|
||||
url: 'https://www.microsoft.com/en-us/sql-server',
|
||||
},
|
||||
{ name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' },
|
||||
{ name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' },
|
||||
{ name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DevOps',
|
||||
category: 'devops',
|
||||
gridArea: 'devops',
|
||||
skills: [
|
||||
{ name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' },
|
||||
{ name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' },
|
||||
{ name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' },
|
||||
{
|
||||
name: 'Azure DevOps',
|
||||
icon: 'devops',
|
||||
url: 'https://azure.microsoft.com/en-us/products/devops',
|
||||
},
|
||||
{ name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' },
|
||||
{ name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' },
|
||||
{ name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Operating Systems',
|
||||
category: 'os',
|
||||
gridArea: 'os',
|
||||
skills: [
|
||||
{ name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' },
|
||||
{ name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' },
|
||||
{ name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' },
|
||||
{ name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Infrastructure as Code',
|
||||
category: 'iac',
|
||||
gridArea: 'iac',
|
||||
skills: [
|
||||
{ name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' },
|
||||
{ name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Hypervisors',
|
||||
category: 'hyperviser',
|
||||
gridArea: 'hyper',
|
||||
skills: [
|
||||
{ name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' },
|
||||
{
|
||||
name: 'Hyper-V',
|
||||
icon: 'hyperv',
|
||||
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
category: 'tools',
|
||||
gridArea: 'tools',
|
||||
skills: [
|
||||
{ name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' },
|
||||
{ name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' },
|
||||
{
|
||||
name: 'Microsoft Office',
|
||||
icon: 'office',
|
||||
url: 'https://www.microsoft.com/microsoft-365',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
readonly categoryTitles = input<Readonly<Record<string, string>>>({});
|
||||
|
||||
protected readonly categories = computed(() => {
|
||||
const titles = this.categoryTitles();
|
||||
return BASE_CATEGORIES.map((category) => ({
|
||||
...category,
|
||||
title: titles[category.category] ?? category.title,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<div class="main">
|
||||
@if (page(); as copy) {
|
||||
<h1>{{ copy.title }}</h1>
|
||||
<p>{{ copy.description }}</p>
|
||||
}
|
||||
<app-skills-grid></app-skills-grid>
|
||||
</div>
|
||||
@@ -1,22 +0,0 @@
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
padding: var(--space-8) var(--content-gutter);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
margin: var(--space-2) 0 var(--space-9);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { PLACEHOLDER_CONTENT } from '../../../core/content/placeholder-content';
|
||||
import { SITE_CONTENT } from '../../../core/content/content.token';
|
||||
import { SITE_CONFIG } from '../../../core/content/site-config';
|
||||
import { Skills } from './skills';
|
||||
|
||||
describe('Skills', () => {
|
||||
let component: Skills;
|
||||
let fixture: ComponentFixture<Skills>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Skills],
|
||||
providers: [{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Skills);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the localized stack title instead of the person name', () => {
|
||||
const heading = (fixture.nativeElement as HTMLElement).querySelector('h1');
|
||||
expect(heading?.textContent?.trim()).toBe('Stack');
|
||||
expect(heading?.textContent).not.toContain(SITE_CONFIG.personName);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { SkillsGrid } from './skills-grid/skills-grid';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skills',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SkillsGrid],
|
||||
templateUrl: './skills.html',
|
||||
styleUrl: './skills.scss',
|
||||
})
|
||||
export class Skills {
|
||||
protected readonly page = inject(ContentService).page('stack');
|
||||
}
|
||||
86
src/app/core/content/content-claims.spec.ts
Normal file
86
src/app/core/content/content-claims.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import {
|
||||
CASE_STUDY_IDS,
|
||||
type CaseStudyCopy,
|
||||
type MetricCopy,
|
||||
type OfferingCopy,
|
||||
type SiteContent,
|
||||
} from './content.contracts';
|
||||
import { SITE_CONTENT_DATA } from './site-content';
|
||||
|
||||
const DELIVERED_OFFER_WORDING =
|
||||
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
|
||||
|
||||
function allMetrics(site: SiteContent): readonly MetricCopy[] {
|
||||
return [...site.home.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
|
||||
}
|
||||
|
||||
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
|
||||
return Object.values(site.services).flatMap((page) => page.offerings);
|
||||
}
|
||||
|
||||
function caseText(caseStudy: CaseStudyCopy): string {
|
||||
return [
|
||||
caseStudy.summary,
|
||||
caseStudy.responsibility,
|
||||
caseStudy.situation,
|
||||
...caseStudy.approach,
|
||||
...caseStudy.outcome,
|
||||
...caseStudy.metrics.map((metric) => `${metric.value} ${metric.label} ${metric.note ?? ''}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describe('content claims and attribution', () => {
|
||||
it('keeps the four public cases and attributes verified facts to the right ones', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
expect(Object.keys(site.cases)).toEqual([...CASE_STUDY_IDS]);
|
||||
expect(CASE_STUDY_IDS).toEqual(['innofocus', 'roesterei', 'myspa', 'hdi']);
|
||||
|
||||
const matchesRuntime = (metric: MetricCopy) =>
|
||||
/8/.test(metric.value) && /90/.test(metric.value);
|
||||
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
|
||||
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
|
||||
expect(site.home.metrics.filter(matchesRuntime)).toHaveLength(1);
|
||||
expect(site.cases.innofocus.metrics.filter(matchesRuntime)).toHaveLength(1);
|
||||
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'innofocus')) {
|
||||
expect(site.cases[caseId].metrics.filter(matchesRuntime)).toHaveLength(0);
|
||||
}
|
||||
|
||||
const leadershipMetrics = allMetrics(site).filter(
|
||||
(metric) =>
|
||||
metric.caseId === 'myspa' &&
|
||||
/4/.test(`${metric.value} ${metric.label}`) &&
|
||||
/2/.test(`${metric.value} ${metric.label}`),
|
||||
);
|
||||
expect(leadershipMetrics.length).toBeGreaterThan(0);
|
||||
|
||||
const hdiText = caseText(site.cases.hdi);
|
||||
expect(hdiText).toMatch(/Azure AKS/);
|
||||
expect(hdiText).toMatch(/Azure App Services/);
|
||||
|
||||
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'hdi')) {
|
||||
expect(caseText(site.cases[caseId])).not.toMatch(/Azure App Services/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps AI delivery claims limited to the two Rösterei tools', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
const aiOfferings = site.services.servicesAi.offerings;
|
||||
const delivered = aiOfferings.filter((offering) => offering.status === 'delivered');
|
||||
|
||||
expect(delivered).toHaveLength(2);
|
||||
expect(delivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
|
||||
|
||||
const otherOfferings = allOfferings(site).filter((offering) => !delivered.includes(offering));
|
||||
expect(otherOfferings.every((offering) => offering.status === 'offer')).toBe(true);
|
||||
expect(
|
||||
otherOfferings.some((offering) =>
|
||||
DELIVERED_OFFER_WORDING.test(`${offering.title} ${offering.body}`),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
116
src/app/core/content/content-completeness.spec.ts
Normal file
116
src/app/core/content/content-completeness.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS } from '../routing/route-ids';
|
||||
import { type SiteContent } from './content.contracts';
|
||||
import { SITE_CONTENT_DATA } from './site-content';
|
||||
|
||||
const TITLE_SUFFIX = ' | Antonio Ledebuhr';
|
||||
const FORBIDDEN =
|
||||
/lorem|todo|tbd|platzhaltertext|placeholder text|wird derzeit aufgebaut|being built|coming soon|xxx/i;
|
||||
|
||||
function walkStrings(
|
||||
value: unknown,
|
||||
visit: (text: string, path: string) => void,
|
||||
path = '',
|
||||
skipKeys: ReadonlySet<string> = new Set(),
|
||||
): void {
|
||||
if (typeof value === 'string') {
|
||||
visit(value, path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => walkStrings(item, visit, `${path}[${index}]`, skipKeys));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (skipKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
walkStrings(child, visit, path ? `${path}.${key}` : key, skipKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('content completeness', () => {
|
||||
it('covers every route in both locales with real page copy', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
|
||||
expect(site.pages.home).toBe(site.home);
|
||||
expect(site.pages.contact).toBe(site.contact);
|
||||
expect(site.pages.projects).toBe(site.projects);
|
||||
expect(site.pages.stack).toBe(site.stack);
|
||||
expect(site.pages.imprint).toBe(site.legal.imprint);
|
||||
expect(site.pages.privacy).toBe(site.legal.privacy);
|
||||
expect(site.pages.servicesAi).toBe(site.services.servicesAi);
|
||||
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
const page = site.pages[routeId];
|
||||
expect(page, `${locale}.${routeId}`).toBeTruthy();
|
||||
expect(page.title.length).toBeGreaterThan(0);
|
||||
expect(page.title.endsWith(TITLE_SUFFIX)).toBe(true);
|
||||
expect(page.description.length).toBeGreaterThanOrEqual(60);
|
||||
expect(page.hero.headline.length).toBeGreaterThan(0);
|
||||
expect(page.hero.headline.includes(TITLE_SUFFIX)).toBe(false);
|
||||
|
||||
if (routeId !== 'notFound') {
|
||||
const filledSections = page.sections.filter((section) =>
|
||||
(section.body ?? []).some((paragraph) => paragraph.trim().length > 0),
|
||||
);
|
||||
expect(filledSections.length, `${locale}.${routeId} sections`).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the content tree free of scaffolding, except legal review fields', () => {
|
||||
const skipped = new Set(['reviewNotice', 'reviewTodos']);
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
const hits: string[] = [];
|
||||
|
||||
walkStrings(
|
||||
site,
|
||||
(text, path) => {
|
||||
if (FORBIDDEN.test(text)) {
|
||||
hits.push(`${path}: ${text}`);
|
||||
}
|
||||
},
|
||||
locale,
|
||||
skipped,
|
||||
);
|
||||
|
||||
expect(hits).toEqual([]);
|
||||
|
||||
for (const legalId of ['imprint', 'privacy'] as const) {
|
||||
const legal = site.legal[legalId];
|
||||
expect(legal.reviewNotice.trim().length).toBeGreaterThan(20);
|
||||
expect(legal.reviewTodos.length).toBeGreaterThan(0);
|
||||
expect(legal.reviewTodos.every((item) => item.trim().length > 0)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat legal review fields as an implicit skip of the rest of the legal pages', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site: SiteContent = SITE_CONTENT_DATA[locale];
|
||||
for (const legalId of ['imprint', 'privacy'] as const) {
|
||||
const legal = site.legal[legalId];
|
||||
const remainderHits: string[] = [];
|
||||
walkStrings(
|
||||
{ hero: legal.hero, sections: legal.sections, legalSections: legal.legalSections },
|
||||
(text, path) => {
|
||||
if (FORBIDDEN.test(text)) {
|
||||
remainderHits.push(`${path}: ${text}`);
|
||||
}
|
||||
},
|
||||
`${locale}.${legalId}`,
|
||||
);
|
||||
expect(remainderHits).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
63
src/app/core/content/content-exclusions.spec.ts
Normal file
63
src/app/core/content/content-exclusions.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../../app.routes';
|
||||
import { SITE_CONTENT } from './content.token';
|
||||
import { SITE_CONTENT_DATA } from './site-content';
|
||||
import { SHELL_COPY } from './shell-copy';
|
||||
import { FOOTER_NAV, PRIMARY_NAV } from '../navigation/navigation';
|
||||
|
||||
const EXCLUDED = new RegExp(
|
||||
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading'].join('|')})\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
function walkStrings(value: unknown, visit: (text: string) => void): void {
|
||||
if (typeof value === 'string') {
|
||||
visit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => walkStrings(item, visit));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach((child) => walkStrings(child, visit));
|
||||
}
|
||||
}
|
||||
|
||||
function collectHits(value: unknown): string[] {
|
||||
const hits: string[] = [];
|
||||
walkStrings(value, (text) => {
|
||||
if (EXCLUDED.test(text)) {
|
||||
hits.push(text);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
describe('content exclusions', () => {
|
||||
it('keeps excluded former stations out of copy data and navigation labels', () => {
|
||||
expect(collectHits(SITE_CONTENT_DATA)).toEqual([]);
|
||||
expect(collectHits(SHELL_COPY)).toEqual([]);
|
||||
expect(collectHits(PRIMARY_NAV)).toEqual([]);
|
||||
expect(collectHits(FOOTER_NAV)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps excluded former stations out of rendered home, projects and about pages', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const paths = ['/', '/en', '/projekte', '/en/projects', '/ueber-mich', '/en/about'];
|
||||
|
||||
for (const path of paths) {
|
||||
await harness.navigateByUrl(path);
|
||||
const text = harness.routeNativeElement?.textContent ?? '';
|
||||
expect(EXCLUDED.test(text), path).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
87
src/app/core/content/content-parity.spec.ts
Normal file
87
src/app/core/content/content-parity.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS } from '../routing/route-ids';
|
||||
import { CASE_STUDY_IDS } from './content.contracts';
|
||||
import { SITE_CONTENT_DATA } from './site-content';
|
||||
|
||||
const STRUCTURAL_KEYS = new Set([
|
||||
'id',
|
||||
'status',
|
||||
'control',
|
||||
'required',
|
||||
'routeId',
|
||||
'caseId',
|
||||
'referenceCaseId',
|
||||
'external',
|
||||
]);
|
||||
|
||||
function assertParity(left: unknown, right: unknown, path: string): void {
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
expect(Array.isArray(left), path).toBe(true);
|
||||
expect(Array.isArray(right), path).toBe(true);
|
||||
expect((left as unknown[]).length, path).toBe((right as unknown[]).length);
|
||||
(left as unknown[]).forEach((item, index) =>
|
||||
assertParity(item, (right as unknown[])[index], `${path}[${index}]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (left && right && typeof left === 'object' && typeof right === 'object') {
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
expect(leftKeys, path).toEqual(rightKeys);
|
||||
|
||||
for (const key of leftKeys) {
|
||||
const nextPath = `${path}.${key}`;
|
||||
const leftValue = (left as Record<string, unknown>)[key];
|
||||
const rightValue = (right as Record<string, unknown>)[key];
|
||||
if (STRUCTURAL_KEYS.has(key)) {
|
||||
expect(leftValue, nextPath).toEqual(rightValue);
|
||||
} else {
|
||||
assertParity(leftValue, rightValue, nextPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof left === 'string' &&
|
||||
typeof right === 'string' &&
|
||||
(/featuredCaseIds\[\d+\]$/.test(path) || /options\[\d+\]\.value$/.test(path))
|
||||
) {
|
||||
expect(left, path).toBe(right);
|
||||
}
|
||||
}
|
||||
|
||||
describe('content parity', () => {
|
||||
it('keeps the German and English trees structurally aligned', () => {
|
||||
assertParity(SITE_CONTENT_DATA.de, SITE_CONTENT_DATA.en, 'site');
|
||||
});
|
||||
|
||||
it('uses different long-form copy in German and English', () => {
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
const german = SITE_CONTENT_DATA.de.pages[routeId];
|
||||
const english = SITE_CONTENT_DATA.en.pages[routeId];
|
||||
|
||||
expect(german.title, routeId).not.toBe(english.title);
|
||||
expect(german.description, routeId).not.toBe(english.description);
|
||||
expect(german.hero.headline, routeId).not.toBe(english.hero.headline);
|
||||
|
||||
german.sections.forEach((section, index) => {
|
||||
const other = english.sections[index];
|
||||
expect(section.body?.join('\n'), `${routeId}.${section.id}`).not.toBe(
|
||||
other?.body?.join('\n'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const caseId of CASE_STUDY_IDS) {
|
||||
const german = SITE_CONTENT_DATA.de.cases[caseId];
|
||||
const english = SITE_CONTENT_DATA.en.cases[caseId];
|
||||
expect(german.summary, caseId).not.toBe(english.summary);
|
||||
expect(german.situation, caseId).not.toBe(english.situation);
|
||||
expect(german.linkLabel, caseId).not.toBe(english.linkLabel);
|
||||
}
|
||||
|
||||
expect(APP_LOCALES).toEqual(['de', 'en']);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export interface CtaCopy {
|
||||
readonly routeId?: RouteId;
|
||||
readonly href?: string;
|
||||
readonly external?: boolean;
|
||||
readonly fragment?: string;
|
||||
}
|
||||
|
||||
export interface SectionCopy extends CopyBlock {
|
||||
@@ -34,17 +35,158 @@ export interface PageCopy {
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
}
|
||||
|
||||
export interface CaseStudySummary {
|
||||
export type CaseStudyId = 'innofocus' | 'roesterei' | 'myspa' | 'hdi';
|
||||
|
||||
export const CASE_STUDY_IDS: readonly CaseStudyId[] = ['innofocus', 'roesterei', 'myspa', 'hdi'];
|
||||
|
||||
export interface MetricCopy {
|
||||
readonly id: string;
|
||||
readonly client: string;
|
||||
readonly headline: string;
|
||||
readonly proof?: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly routeId?: RouteId;
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly note?: string;
|
||||
readonly caseId?: CaseStudyId;
|
||||
}
|
||||
|
||||
export type LocalizedPages = Partial<Record<RouteId, PageCopy>>;
|
||||
export interface ProcessStepCopy {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface CaseStudyCopy {
|
||||
readonly id: CaseStudyId;
|
||||
readonly client: string;
|
||||
readonly role: string;
|
||||
readonly period: string;
|
||||
readonly domain: string;
|
||||
readonly headline: string;
|
||||
readonly summary: string;
|
||||
readonly responsibility: string;
|
||||
readonly situation: string;
|
||||
readonly approach: readonly string[];
|
||||
readonly outcome: readonly string[];
|
||||
readonly metrics: readonly MetricCopy[];
|
||||
readonly stack: readonly string[];
|
||||
readonly tags: readonly string[];
|
||||
readonly linkLabel: string;
|
||||
readonly transparencyNote?: string;
|
||||
}
|
||||
|
||||
export type OfferingStatus = 'delivered' | 'offer';
|
||||
|
||||
export interface OfferingCopy {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly status: OfferingStatus;
|
||||
readonly referenceCaseId?: CaseStudyId;
|
||||
}
|
||||
|
||||
export interface ServiceSequenceCopy {
|
||||
readonly situation: SectionCopy;
|
||||
readonly diagnosis: SectionCopy;
|
||||
readonly implementation: SectionCopy;
|
||||
readonly operation: SectionCopy;
|
||||
readonly referenceCaseId: CaseStudyId;
|
||||
readonly referenceHeadline: string;
|
||||
readonly referenceNote: string;
|
||||
readonly inquiry: SectionCopy;
|
||||
}
|
||||
|
||||
export interface ServicePageCopy extends PageCopy {
|
||||
readonly sequence: ServiceSequenceCopy;
|
||||
readonly offerings: readonly OfferingCopy[];
|
||||
}
|
||||
|
||||
export interface AudienceEntryCopy {
|
||||
readonly id: 'recruiters' | 'companies';
|
||||
readonly headline: string;
|
||||
readonly body: string;
|
||||
readonly bullets: readonly string[];
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
}
|
||||
|
||||
export interface HomePageCopy extends PageCopy {
|
||||
readonly profile: readonly string[];
|
||||
readonly metrics: readonly MetricCopy[];
|
||||
readonly audiences: readonly AudienceEntryCopy[];
|
||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||
}
|
||||
|
||||
export type ContactFieldId =
|
||||
'name' | 'email' | 'company' | 'projectType' | 'situation' | 'timeframe';
|
||||
|
||||
export interface ContactFieldOption {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface ContactFieldCopy {
|
||||
readonly id: ContactFieldId;
|
||||
readonly control: 'text' | 'email' | 'select' | 'textarea';
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly required: boolean;
|
||||
readonly options?: readonly ContactFieldOption[];
|
||||
}
|
||||
|
||||
export interface ContactPageCopy extends PageCopy {
|
||||
readonly fields: readonly ContactFieldCopy[];
|
||||
readonly mailSubject: string;
|
||||
readonly mailIntro: string;
|
||||
readonly mailSignature: string;
|
||||
readonly submitLabel: string;
|
||||
readonly incompleteHint: string;
|
||||
readonly requiredMarkerLabel: string;
|
||||
readonly directEmailLabel: string;
|
||||
readonly calendarLabel: string;
|
||||
readonly noBackendNote: string;
|
||||
}
|
||||
|
||||
export interface LegalSectionCopy {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly body: readonly string[];
|
||||
}
|
||||
|
||||
export interface LegalPageCopy extends PageCopy {
|
||||
readonly reviewNotice: string;
|
||||
readonly reviewTodos: readonly string[];
|
||||
readonly legalSections: readonly LegalSectionCopy[];
|
||||
}
|
||||
|
||||
export interface StackGroupCopy {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly body?: string;
|
||||
}
|
||||
|
||||
export interface StackPageCopy extends PageCopy {
|
||||
readonly groups: readonly StackGroupCopy[];
|
||||
}
|
||||
|
||||
export interface CaseStudyLabels {
|
||||
readonly situation: string;
|
||||
readonly approach: string;
|
||||
readonly outcome: string;
|
||||
readonly stack: string;
|
||||
readonly tags: string;
|
||||
}
|
||||
|
||||
export interface ProjectsPageCopy extends PageCopy {
|
||||
readonly caseLabels: CaseStudyLabels;
|
||||
}
|
||||
|
||||
export type ServicePageId =
|
||||
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
||||
|
||||
export interface SiteContent {
|
||||
readonly pages: LocalizedPages;
|
||||
readonly pages: Record<RouteId, PageCopy>;
|
||||
readonly home: HomePageCopy;
|
||||
readonly services: Record<ServicePageId, ServicePageCopy>;
|
||||
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
||||
readonly contact: ContactPageCopy;
|
||||
readonly projects: ProjectsPageCopy;
|
||||
readonly stack: StackPageCopy;
|
||||
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { computed, inject, Injectable, type Signal } from '@angular/core';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
import { type PageCopy } from './content.contracts';
|
||||
import {
|
||||
CASE_STUDY_IDS,
|
||||
type CaseStudyCopy,
|
||||
type CaseStudyId,
|
||||
type ContactPageCopy,
|
||||
type HomePageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type ServicePageCopy,
|
||||
type ServicePageId,
|
||||
type StackPageCopy,
|
||||
} from './content.contracts';
|
||||
import { SITE_CONTENT } from './content.token';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -9,7 +21,42 @@ export class ContentService {
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly content = inject(SITE_CONTENT);
|
||||
|
||||
page(routeId: RouteId): Signal<PageCopy | undefined> {
|
||||
page(routeId: RouteId): Signal<PageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].pages[routeId]);
|
||||
}
|
||||
|
||||
home(): Signal<HomePageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].home);
|
||||
}
|
||||
|
||||
service(id: ServicePageId): Signal<ServicePageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].services[id]);
|
||||
}
|
||||
|
||||
caseStudy(id: CaseStudyId): Signal<CaseStudyCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].cases[id]);
|
||||
}
|
||||
|
||||
cases(): Signal<readonly CaseStudyCopy[]> {
|
||||
return computed(() => {
|
||||
const cases = this.content[this.localeService.locale()].cases;
|
||||
return CASE_STUDY_IDS.map((id) => cases[id]);
|
||||
});
|
||||
}
|
||||
|
||||
contact(): Signal<ContactPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].contact);
|
||||
}
|
||||
|
||||
projects(): Signal<ProjectsPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].projects);
|
||||
}
|
||||
|
||||
stack(): Signal<StackPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].stack);
|
||||
}
|
||||
|
||||
legal(id: 'imprint' | 'privacy'): Signal<LegalPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].legal[id]);
|
||||
}
|
||||
}
|
||||
|
||||
181
src/app/core/content/de/cases.ts
Normal file
181
src/app/core/content/de/cases.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { type CaseStudyCopy } from '../content.contracts';
|
||||
|
||||
export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
|
||||
innofocus: {
|
||||
id: 'innofocus',
|
||||
client: 'Innofocus / reifen.com',
|
||||
role: 'Senior Backend- und Datenbankingenieur',
|
||||
period: 'seit 09/2025, laufend',
|
||||
domain: 'eCommerce / ERP',
|
||||
headline: 'Normalisierte ERP-Datenmigration in Microsoft SQL Server',
|
||||
summary:
|
||||
'Ersatz des bestehenden ERP durch ein neues, normalisiertes System. Die Migration aller Altdaten läuft direkt in Microsoft SQL Server.',
|
||||
responsibility:
|
||||
'Alleinverantwortung: das Migrationsteam besteht aus einer Person. Als einziger Freiberufler steht er im direkten Kontakt mit dem Endkunden.',
|
||||
situation:
|
||||
'Das bestehende ERP wird durch ein neues, normalisiertes System ersetzt. Alle Altdaten werden direkt in Microsoft SQL Server migriert. Die Altstruktur ist nicht normalisiert, die Zielstruktur ist es; die Migration besteht deshalb vor allem aus Datenbereinigung, Normalisierung und Dokumentation für eine prüfende Stelle. Die Migration war zuvor bei einem anderen Dienstleister gelaufen und wurde übernommen.',
|
||||
approach: [
|
||||
'Umfangreiche T-SQL-Stored-Procedures für Transformation, Normalisierung und Bereinigung.',
|
||||
'Selektive Arbeit an der Anwendung selbst mit C#, .NET und Angular.',
|
||||
'Anmeldung über Keycloak mit OAuth 2 / OIDC.',
|
||||
'Betrieb auf Kubernetes, GitOps-Deployments über Argo CD, Git und Pipelines in Azure DevOps.',
|
||||
],
|
||||
outcome: [
|
||||
'Die Laufzeit der Migration liegt bei etwa 90 Minuten statt zuvor rund acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
|
||||
'Die Zielstruktur ist normalisiert und für die prüfende Stelle dokumentiert.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → ca. 90 min',
|
||||
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
|
||||
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
stack: [
|
||||
'Microsoft SQL Server',
|
||||
'T-SQL',
|
||||
'C#',
|
||||
'.NET',
|
||||
'Angular',
|
||||
'Keycloak',
|
||||
'OAuth 2 / OIDC',
|
||||
'Kubernetes',
|
||||
'Argo CD',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL'],
|
||||
linkLabel: 'Fall lesen: Innofocus / reifen.com',
|
||||
},
|
||||
roesterei: {
|
||||
id: 'roesterei',
|
||||
client: 'Rösterei Tangermünde',
|
||||
role: 'Gesamtverantwortung für die Technik',
|
||||
period: 'seit 10/2023, laufend',
|
||||
domain: 'eCommerce und interne IT',
|
||||
headline: 'Laden-IT, Shop und interne Werkzeuge aus einer Hand',
|
||||
summary:
|
||||
'Ursprünglich sollte ein neuer Shop entstehen. Der Auftrag ist weit darüber hinausgewachsen und umfasst die gesamte Technik des Unternehmens.',
|
||||
responsibility:
|
||||
'Vollständige Verantwortung für die Technik: Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, der neue Onlineshop und neues Marketing.',
|
||||
situation:
|
||||
'Die Rösterei brauchte zunächst einen neuen Shop. Daraus wurde die Verantwortung für Netz, Server, Geräte, Microsoft 365, den Shop und Marketing. Anforderungen kommen direkt von nicht-technischen Stakeholdern; die Markenführung entsteht in Zusammenarbeit mit einem Designer.',
|
||||
approach: [
|
||||
'Zeitgemäße und kostengünstige Infrastruktur statt unnötiger Komplexität.',
|
||||
'Shop-Version 1: Shopify mit eigenem Theme und eigenen Komponenten in Liquid.',
|
||||
'Shop-Version 2: Liquid und Hydrogen, mit eigenen angebundenen Diensten.',
|
||||
'Interne Werkzeuge für geringere Kosten und mehr Autonomie: ein Generator für Vektorgrafiken in Werbemitteln, ein KI-Werkzeug zur Kategorisierung von Kundenbewertungen und ein KI-Werkzeug für die schnelle Verwaltung des neuen Shops, vergleichbar mit Shopify Sidekick und in die interne Werkzeugkette eingebunden.',
|
||||
],
|
||||
outcome: [
|
||||
'Die Technik des Unternehmens liegt in einer Hand, vom Netz bis zum Shop.',
|
||||
'Zwei KI-Werkzeuge sind im internen Betrieb der Rösterei im Einsatz: die Kategorisierung von Bewertungen und der Shop-Assistent.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Shopify',
|
||||
'Liquid',
|
||||
'Hydrogen',
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'SCSS',
|
||||
'Java Spring WebFlux',
|
||||
'Java OpenAI library',
|
||||
'MariaDB',
|
||||
'Docker',
|
||||
'Kubernetes',
|
||||
'Proxmox',
|
||||
'GitLab CE',
|
||||
'Microsoft 365',
|
||||
],
|
||||
tags: ['eCommerce', 'interne IT', 'Shopify', 'KI-Werkzeuge'],
|
||||
linkLabel: 'Fall lesen: Rösterei Tangermünde',
|
||||
transparencyNote:
|
||||
'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||
},
|
||||
myspa: {
|
||||
id: 'myspa',
|
||||
client: 'Aracom IT Services / MySpa',
|
||||
role: 'Lead Backend / DevOps',
|
||||
period: '03/2024 – 09/2024',
|
||||
domain: 'IoT',
|
||||
headline: 'Backend als Schnittstelle zwischen Haus, Zahlung und Raumtechnik',
|
||||
summary:
|
||||
'Ersatz der Altsoftware durch neue Individualsoftware. Das Backend verbindet Website, internes Admin-Werkzeug, Zahlungsterminal, Schließfächer, smarte Geräte und Touchpanels.',
|
||||
responsibility:
|
||||
'Leitung des Backend-Teams (4 Personen) und des DevOps-Teams (2 Personen) bis zum Start der Software. Direkter Kundenkontakt für Anforderungen.',
|
||||
situation:
|
||||
'Die vorhandene Software sollte durch eine neue Eigenentwicklung ersetzt werden. Das Backend ist die Schnittstelle zwischen Website, internem Admin-Werkzeug, Zahlungsterminal, Schließfächern, smarten Geräten (Licht, Fernseher) und Touchpanels für Bestellungen und Raumtechnik.',
|
||||
approach: [
|
||||
'Backend mit C# und .NET 8, REST-API, Entity Framework 8 im Data-First-Ansatz.',
|
||||
'Verwaltung des MySQL-Servers vor Ort.',
|
||||
'Testgetriebene Entwicklung mit Unit- und Integrationstests (xUnit).',
|
||||
'OAuth 2 über ein Identity-Provider-Framework, dazu MQTT und Hangfire.',
|
||||
'Migration von Entwicklungs-, QS- und Produktivsystemen von Diensten vor Ort in ein Docker-Cluster.',
|
||||
'CI/CD-Pipelines mit GitLab EE, Arbeit im Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Das Backend verbindet die genannten Kanäle bis zum Softwarestart.',
|
||||
'Entwicklungs-, QS- und Produktivsysteme laufen im Docker-Cluster.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'C#',
|
||||
'.NET 8',
|
||||
'ASP.NET Core',
|
||||
'Entity Framework 8',
|
||||
'xUnit',
|
||||
'MySQL',
|
||||
'OAuth 2',
|
||||
'MQTT',
|
||||
'Hangfire',
|
||||
'Docker',
|
||||
'GitLab EE',
|
||||
],
|
||||
tags: ['IoT', 'Backend', 'DevOps', 'Docker'],
|
||||
linkLabel: 'Fall lesen: Aracom IT Services / MySpa',
|
||||
},
|
||||
hdi: {
|
||||
id: 'hdi',
|
||||
client: 'HDI Specialty',
|
||||
role: 'Senior Fullstack / DevOps',
|
||||
period: '05/2023 – 01/2024',
|
||||
domain: 'Versicherung',
|
||||
headline: 'Exposure Management für Simulationen und BaFin-Auswertungen',
|
||||
summary:
|
||||
'Software, die Simulationsfolgen automatisiert, um bestimmte Risiken zu bestimmen, Simulationen stochastisch auswertet, Policen zentral sammelt und Auswertungen aller Policen für die BaFin erzeugt.',
|
||||
responsibility:
|
||||
'Senior-Fullstack- und DevOps-Arbeit an der Exposure-Management-Software, einschließlich der Migration der Umgebungen auf Azure AKS.',
|
||||
situation:
|
||||
'HDI Specialty braucht Software für Exposure Management: Simulationsfolgen sollen bestimmte Risiken bestimmen, stochastisch ausgewertet werden, Policen zentral sammeln und Auswertungen aller Policen für die BaFin liefern.',
|
||||
approach: [
|
||||
'Angular-Einzelanwendung mit TypeScript, HTML, SCSS/CSS, Ngrx Store und Ngx Pipes.',
|
||||
'Backend mit C# und .NET, REST-API und Entity Framework.',
|
||||
'Testgetriebene Entwicklung mit Unit- und Integrationstests (xUnit).',
|
||||
'Verwaltung von Azure SQL Server.',
|
||||
'Anmeldung und Berechtigungen über Okta mit OAuth 2.',
|
||||
'CI/CD-Pipelines und Projektsteuerung mit Azure DevOps, Arbeit im Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Entwicklungs-, QS- und Produktivsysteme wurden von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS migriert.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'Ngrx',
|
||||
'Ngx Pipes',
|
||||
'C#',
|
||||
'.NET',
|
||||
'Entity Framework',
|
||||
'xUnit',
|
||||
'Azure SQL Server',
|
||||
'Okta',
|
||||
'OAuth 2',
|
||||
'Azure AKS',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure'],
|
||||
linkLabel: 'Fall lesen: HDI Specialty',
|
||||
},
|
||||
};
|
||||
99
src/app/core/content/de/home.ts
Normal file
99
src/app/core/content/de/home.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_DE: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Startseite | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack- und DevOps-Ingenieur in Tangermünde: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung, freelance seit 04/2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
||||
proof:
|
||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.',
|
||||
playfulLine: 'IT mit Drehmoment',
|
||||
},
|
||||
profile: [
|
||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
|
||||
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
||||
'Freelance seit 04/2023, mit Sitz in Tangermünde.',
|
||||
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'ca. 7 Jahre',
|
||||
label: 'berufliche Erfahrung mit Webanwendungen',
|
||||
},
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → ca. 90 min',
|
||||
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
|
||||
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
||||
caseId: 'myspa',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
{
|
||||
id: 'recruiters',
|
||||
headline: 'Für Recruiterinnen und Recruiter',
|
||||
body: 'Ein kurzer Überblick über Erfahrung, ausgewählte Fälle, den technischen Stack und den Lebenslauf.',
|
||||
bullets: [
|
||||
'Rund sieben Jahre Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt und Teamverantwortung.',
|
||||
'Vier öffentlich dargestellte Fälle aus eCommerce, interner IT, IoT und Versicherung.',
|
||||
'Heimspiel Java und Spring, dazu C#/.NET, Angular, SQL sowie Docker und Kubernetes.',
|
||||
'Lebenslauf als PDF zum direkten Download.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{
|
||||
label: 'Lebenslauf als PDF',
|
||||
href: SITE_CONFIG.cvAssetPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'Für Unternehmen',
|
||||
body: 'Vier Leistungsbereiche, vom ersten Workshop bis zum Betrieb — und ein kurzes Briefing für die Anfrage.',
|
||||
bullets: [
|
||||
'Software: Fullstack-Produkte mit Java, .NET, Angular und Datenarbeit in SQL.',
|
||||
'Hardware und Netzwerk: Server, Geräte, Microsoft 365 und wartbare Infrastruktur.',
|
||||
'Cluster: Docker und Kubernetes on-premise und auf Azure AKS, inklusive Pipelines.',
|
||||
'KI-Integration: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde, weitere Bausteine als Angebot.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
body: [
|
||||
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Diese Seite zeigt eine kuratierte Auswahl von Stationen; der vollständige Lebenslauf steht als PDF bereit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
body: [
|
||||
'Die vier öffentlichen Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Leistungen', routeId: 'services' },
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
365
src/app/core/content/de/pages.ts
Normal file
365
src/app/core/content/de/pages.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import {
|
||||
type ContactPageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type StackPageCopy,
|
||||
} from '../content.contracts';
|
||||
|
||||
export const PROJECTS_DE: ProjectsPageCopy = {
|
||||
routeId: 'projects',
|
||||
title: 'Projekte | Antonio Ledebuhr',
|
||||
description:
|
||||
'Vier öffentliche Fälle: Innofocus / reifen.com, Rösterei Tangermünde, MySpa und HDI Specialty. Eine bewusst schmale Auswahl, der vollständige Lebenslauf liegt als Download bereit.',
|
||||
hero: {
|
||||
headline: 'Vier öffentliche Fälle, bewusst ausgewählt',
|
||||
proof:
|
||||
'Die Seite zeigt eine kuratierte Auswahl. Der vollständige Lebenslauf steht als PDF zum Download bereit.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'selection',
|
||||
headline: 'Warum nur diese vier',
|
||||
body: [
|
||||
'Öffentlich stehen vier Fälle: Innofocus / reifen.com, die Rösterei Tangermünde, MySpa und HDI Specialty. Weitere Stationen gehören in den Lebenslauf, nicht auf diese Seiten.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'Was jeder Fall festhält',
|
||||
body: [
|
||||
'Jeder Fall dokumentiert Auftrag, Rolle, Zeitraum, Lage, Vorgehen und Ergebnis. Zahlen stehen nur dort, wo sie belegt sind.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
situation: 'Lage',
|
||||
approach: 'Vorgehen',
|
||||
outcome: 'Ergebnis',
|
||||
stack: 'Technik',
|
||||
tags: 'Schlagworte',
|
||||
},
|
||||
};
|
||||
|
||||
export const STACK_DE: StackPageCopy = {
|
||||
routeId: 'stack',
|
||||
title: 'Technik-Stack | Antonio Ledebuhr',
|
||||
description:
|
||||
'Der öffentliche Stack in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
|
||||
hero: {
|
||||
headline: 'Der Stack, der auf diesen Seiten vorkommt',
|
||||
proof:
|
||||
'Die Gruppen gehören zur Produktarbeit in Java und Spring sowie in C# und .NET, zur Datenarbeit in SQL Server, zu Containern und Clustern und zu Servern vor Ort.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'Eine Auswahl, keine Rangliste',
|
||||
body: ['Die Übersicht ist eine Auswahl aus der öffentlichen Arbeit, keine Rangliste.'],
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
headline: 'Wo die Gruppen in der Arbeit vorkommen',
|
||||
body: [
|
||||
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Server und Virtualisierung vor Ort.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Über mich', routeId: 'about' },
|
||||
],
|
||||
groups: [
|
||||
{ id: 'programming', title: 'Programmierung' },
|
||||
{ id: 'db', title: 'Datenbanken' },
|
||||
{ id: 'devops', title: 'DevOps' },
|
||||
{ id: 'os', title: 'Betriebssysteme' },
|
||||
{ id: 'iac', title: 'Infrastructure as Code' },
|
||||
{ id: 'hyperviser', title: 'Hypervisor' },
|
||||
{ id: 'tools', title: 'Werkzeuge' },
|
||||
],
|
||||
};
|
||||
|
||||
export const ABOUT_DE: PageCopy = {
|
||||
routeId: 'about',
|
||||
title: 'Über mich | Antonio Ledebuhr',
|
||||
description:
|
||||
'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur in Tangermünde. Freelance seit 04/2023, Deutsch als Muttersprache, Englisch verhandlungssicher.',
|
||||
hero: {
|
||||
headline: 'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur',
|
||||
proof:
|
||||
'Sitz in Tangermünde. Freelance seit 04/2023. Deutsch als Muttersprache, Englisch verhandlungssicher.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Berufliches Profil',
|
||||
body: [
|
||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
||||
'Heimspiel ist Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Dazu kommen C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) und Angular (TypeScript, RxJS-zeitige SPA-Arbeit, Ngrx, SCSS).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'working',
|
||||
headline: 'Arbeitsweise',
|
||||
body: [
|
||||
'Sauberer Code, tragfähige Architektur, Domain-driven Design und testgetriebene Entwicklung. Neu in eine Fachlichkeit einsteigen gehört zur Arbeit, nicht an ihren Rand.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-and-language',
|
||||
headline: 'Kundenkontakt, Team und Sprachen',
|
||||
body: [
|
||||
'Direkter Kundenkontakt und Teamverantwortung sind Teil der bisherigen Arbeit, nicht eine spätere Ausbaustufe.',
|
||||
'Deutsch ist Muttersprache. Englisch ist verhandlungssicher.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'accent',
|
||||
headline: 'Rösterei und Drehmoment',
|
||||
body: [
|
||||
'Die Rösterei-Arbeit bleibt der persönliche Akzent: Rösten und Drehmoment statt Leerlauf. Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
|
||||
export const CONTACT_DE: ContactPageCopy = {
|
||||
routeId: 'contact',
|
||||
title: 'Kontakt | Antonio Ledebuhr',
|
||||
description:
|
||||
'Kurzes Projektbriefing im Browser. Es entsteht nur eine lokale E-Mail an info@antoniolede.de, ohne Versand an diesen Server.',
|
||||
hero: {
|
||||
headline: 'Kurzes Briefing, danach das lokale E-Mail-Programm',
|
||||
proof:
|
||||
'Die Felder bleiben im Browser. Der Knopf öffnet nur eine vorausgefüllte Nachricht an info@antoniolede.de.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'how',
|
||||
headline: 'Was mit den Angaben passiert',
|
||||
body: [
|
||||
'Nichts wird an diesen Server gesendet. Aus den ausgefüllten Feldern entsteht eine mailto-Nachricht, die das lokale E-Mail-Programm öffnet.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'direct',
|
||||
headline: 'Direkt schreiben',
|
||||
body: [
|
||||
'Wer das Briefing nicht nutzen möchte, schreibt direkt an info@antoniolede.de. Ein Kalenderlink ist derzeit nicht hinterlegt.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [],
|
||||
fields: [
|
||||
{ id: 'name', control: 'text', label: 'Name', required: true },
|
||||
{ id: 'email', control: 'email', label: 'E-Mail', required: true },
|
||||
{ id: 'company', control: 'text', label: 'Unternehmen', hint: 'freiwillig', required: false },
|
||||
{
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Art des Vorhabens',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
||||
{ value: 'clusters', label: 'Cluster' },
|
||||
{ value: 'ai', label: 'KI-Integration' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'situation',
|
||||
control: 'textarea',
|
||||
label: 'Lage',
|
||||
hint: 'Was steht an, und woran soll die Zusammenarbeit ansetzen?',
|
||||
required: true,
|
||||
},
|
||||
{ id: 'timeframe', control: 'text', label: 'Zeitrahmen', hint: 'freiwillig', required: false },
|
||||
],
|
||||
mailSubject: 'Projektanfrage über antoniolede.de',
|
||||
mailIntro: 'Kurzes Briefing von der Website:',
|
||||
mailSignature: 'Gesendet über das Kontaktbriefing auf antoniolede.de.',
|
||||
submitLabel: 'E-Mail-Programm öffnen',
|
||||
incompleteHint: 'Bitte noch ausfüllen:',
|
||||
requiredMarkerLabel: 'Pflichtfeld',
|
||||
directEmailLabel: 'Direkt an info@antoniolede.de schreiben',
|
||||
calendarLabel: 'Termin finden',
|
||||
noBackendNote:
|
||||
'Es gibt kein Formular-Backend. Es wird nichts an diesen Server übertragen. Der Knopf öffnet nur das lokale E-Mail-Programm.',
|
||||
};
|
||||
|
||||
export const IMPRINT_DE: LegalPageCopy = {
|
||||
routeId: 'imprint',
|
||||
title: 'Impressum | Antonio Ledebuhr',
|
||||
description:
|
||||
'Anbieterkennzeichnung von Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde. Der Text ist ungeprüft und vor der Veröffentlichung vom Seitenbetreiber zu prüfen.',
|
||||
hero: {
|
||||
headline: 'Impressum',
|
||||
proof:
|
||||
'Name, Anschrift und E-Mail sind aus dem Lebenslauf übernommen. Weitere Pflichtangaben fehlen bewusst.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was hier steht',
|
||||
body: [
|
||||
'Dieser Text nennt Name, Anschrift, E-Mail und die Verantwortung für den Inhalt. Er ist kein fertiges Impressum.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
headline: 'Was fehlt',
|
||||
body: [
|
||||
'Umsatzsteuer-Identifikationsnummer, Registerdaten, Aufsicht, Berufshaftpflicht und Telefon stehen nicht auf dieser Seite, weil sie hier nicht belegt sind.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [{ label: 'Datenschutz', routeId: 'privacy' }],
|
||||
reviewNotice:
|
||||
'Achtung: Dieser Rechtstext ist ungeprüft. Vor jeder Veröffentlichung muss der Seitenbetreiber ihn prüfen und die offenen Punkte unten ergänzen.',
|
||||
reviewTodos: [
|
||||
'Umsatzsteuer-Identifikationsnummer, sofern eine besteht',
|
||||
'Registergericht und Registernummer, sofern ein Eintrag besteht',
|
||||
'Aufsichtsbehörde, sofern eine zuständig ist',
|
||||
'Berufshaftpflichtversicherung, sofern eine besteht',
|
||||
'Telefonnummer, sofern sie veröffentlicht werden soll',
|
||||
'Hosting-Anbieter und dessen Datenverarbeitung',
|
||||
],
|
||||
legalSections: [
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'Anbieter',
|
||||
body: [
|
||||
'Antonio Ledebuhr',
|
||||
'Kirchstr. 19',
|
||||
'39590 Tangermünde',
|
||||
'Germany',
|
||||
'E-Mail: info@antoniolede.de',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'responsibility',
|
||||
title: 'Verantwortlich für den Inhalt',
|
||||
body: [
|
||||
'Verantwortlich für den Inhalt dieser Seiten ist Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'completeness',
|
||||
title: 'Keine Vollständigkeit',
|
||||
body: [
|
||||
'Diese Seite behauptet keine rechtliche Vollständigkeit. Offene Angaben stehen in der Prüfungsliste oben und müssen vor der Veröffentlichung vom Seitenbetreiber ergänzt werden.',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const PRIVACY_DE: LegalPageCopy = {
|
||||
routeId: 'privacy',
|
||||
title: 'Datenschutz | Antonio Ledebuhr',
|
||||
description:
|
||||
'Hinweise zur Verarbeitung auf dieser statischen, serverseitig gerenderten Website ohne eigenes Backend. Der Text ist ungeprüft und vor der Veröffentlichung zu ergänzen.',
|
||||
hero: {
|
||||
headline: 'Datenschutz',
|
||||
proof:
|
||||
'Die Website hat kein eigenes Backend. Das Kontaktbriefing bleibt im Browser und erzeugt nur eine mailto-Nachricht.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was dieser Text leistet',
|
||||
body: [
|
||||
'Er beschreibt den öffentlichen Stand der Seite, so weit er hier belegt ist. Er ist keine abgeschlossene Datenschutzerklärung.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
headline: 'Was der Seitenbetreiber noch eintragen muss',
|
||||
body: [
|
||||
'Hosting-Anbieter, dessen Verarbeitung und weitere gesetzlich geforderte Punkte stehen in der Prüfungsliste und fehlen hier bewusst.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [{ label: 'Impressum', routeId: 'imprint' }],
|
||||
reviewNotice:
|
||||
'Achtung: Dieser Rechtstext ist ungeprüft. Vor jeder Veröffentlichung muss der Seitenbetreiber ihn prüfen und die offenen Punkte unten ergänzen.',
|
||||
reviewTodos: [
|
||||
'Hosting-Anbieter und dessen Auftragsverarbeitung',
|
||||
'Serverstandort und eingesetzte Unterauftragsverarbeiter',
|
||||
'Kontaktmöglichkeit für datenschutzrechtliche Anfragen, sofern nicht die E-Mail genügt',
|
||||
'Weitere gesetzlich geforderte Angaben, sobald sie feststehen',
|
||||
],
|
||||
legalSections: [
|
||||
{
|
||||
id: 'controller',
|
||||
title: 'Verantwortlicher',
|
||||
body: [
|
||||
'Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany, E-Mail: info@antoniolede.de.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'site-nature',
|
||||
title: 'Art dieser Website',
|
||||
body: [
|
||||
'Diese Website ist eine statische, serverseitig gerenderte Website ohne eigenes Backend. Es gibt kein Nutzerkonto und keine serverseitige Formularannahme auf dieser Domain.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-briefing',
|
||||
title: 'Kontaktbriefing',
|
||||
body: [
|
||||
'Das Kontaktbriefing läuft vollständig im Browser. Es setzt nur einen mailto-Link zusammen. Es werden keine Formulardaten an diese Website übertragen.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'E-Mail-Korrespondenz',
|
||||
body: [
|
||||
'Wenn eine Nachricht an info@antoniolede.de geht, wird die Korrespondenz zum Zweck der Anfrage verarbeitet. Eine andere Nutzung ist hier nicht beschrieben.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: 'Rechte betroffener Personen',
|
||||
body: [
|
||||
'Betroffene Personen haben die allgemeinen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Widerspruch und Beschwerde bei einer Aufsichtsbehörde. Die konkrete Ausübung hängt vom jeweiligen Vorgang ab.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hosting',
|
||||
title: 'Hosting',
|
||||
body: [
|
||||
'Der Hosting-Anbieter und seine Verarbeitung sind vom Seitenbetreiber noch einzutragen. An dieser Stelle wird kein Anbieter genannt.',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_DE: PageCopy = {
|
||||
routeId: 'notFound',
|
||||
title: 'Seite nicht gefunden | Antonio Ledebuhr',
|
||||
description:
|
||||
'Diese Adresse führt auf keine öffentliche Seite. Weiter geht es über Start, Leistungen, Projekte oder Kontakt.',
|
||||
hero: {
|
||||
headline: 'Diese Seite gibt es hier nicht',
|
||||
proof:
|
||||
'Die Adresse gehört zu keiner öffentlichen Route. Die vier Links unten führen zurück in den Bestand.',
|
||||
},
|
||||
sections: [],
|
||||
ctas: [
|
||||
{ label: 'Zur Startseite', routeId: 'home' },
|
||||
{ label: 'Leistungen', routeId: 'services' },
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
424
src/app/core/content/de/services.ts
Normal file
424
src/app/core/content/de/services.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
||||
|
||||
export const SERVICES_OVERVIEW_DE: PageCopy = {
|
||||
routeId: 'services',
|
||||
title: 'Leistungen | Antonio Ledebuhr',
|
||||
description:
|
||||
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
|
||||
hero: {
|
||||
headline: 'Vier Leistungsbereiche, ein Ansprechpartner',
|
||||
proof:
|
||||
'Software, Hardware und Netzwerk, Cluster sowie KI-Integration. Der Weg geht von der Lage über die Diagnose zur Umsetzung und in den Betrieb.',
|
||||
playfulLine: 'Systeme, die nicht im Leerlauf laufen',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'areas',
|
||||
headline: 'Die vier Bereiche',
|
||||
body: [
|
||||
'Software umfasst Fullstack-Produktarbeit mit Java und Spring Boot, C# und .NET, Angular, REST-APIs, Domain-driven Design, testgetriebener Entwicklung sowie SQL- und T-SQL-Datenarbeit.',
|
||||
'Hardware und Netzwerk umfasst Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365 sowie Server und Virtualisierung vor Ort.',
|
||||
'Cluster umfasst Docker und Kubernetes on-premise und auf Azure AKS, die Migration von einzelnen Hosts und PaaS auf Cluster, GitLab CI/CD und Azure DevOps sowie GitOps mit Argo CD.',
|
||||
'KI-Integration ist der größte Bereich: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde und weitere Bausteine, die je Auftrag geschnitten und geprüft werden.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'who',
|
||||
headline: 'Für wen das gedacht ist',
|
||||
body: [
|
||||
'Für Unternehmen, die einen Ansprechpartner vom ersten Anforderungsworkshop bis in den Produktivbetrieb suchen — ohne extra Schicht zwischen Fachseite und Technik.',
|
||||
'Für Recruiterinnen und Recruiter, die die öffentliche Auswahl und den Stack prüfen wollen, bevor sie den Lebenslauf lesen.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headline: 'So läuft eine Zusammenarbeit',
|
||||
body: [
|
||||
'Zuerst die Lage, dann die Diagnose, danach die Umsetzung und zum Schluss Betrieb oder Übergabe. Jede Detailseite folgt dieser Reihenfolge und nennt einen passenden öffentlichen Fall.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Software', routeId: 'servicesSoftware' },
|
||||
{ label: 'Hardware und Netzwerk', routeId: 'servicesHardwareNetwork' },
|
||||
{ label: 'Cluster', routeId: 'servicesClusters' },
|
||||
{ label: 'KI-Integration', routeId: 'servicesAi' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
|
||||
export const SERVICES_SOFTWARE_DE: ServicePageCopy = {
|
||||
routeId: 'servicesSoftware',
|
||||
title: 'Softwareentwicklung | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack-Produktarbeit mit Java und Spring Boot, C# und .NET, Angular, REST-APIs, Domain-driven Design, Tests und SQL-Datenarbeit.',
|
||||
hero: {
|
||||
headline: 'Fullstack-Produktarbeit, von der Anforderung bis zur Auslieferung',
|
||||
proof:
|
||||
'Java und Spring Boot, C# und .NET, Angular, REST-APIs, Domain-driven Design, testgetriebene Entwicklung und SQL- bzw. T-SQL-Datenarbeit. Anmeldung mit Keycloak und OAuth 2.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was dieser Bereich abdeckt',
|
||||
body: [
|
||||
'Neue Produktsoftware und der Umbau bestehender Anwendungen. Die Arbeit reicht von der Domäne über die API bis zur Oberfläche, inklusive Datenmodell und Anmeldung.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'Wann das passt',
|
||||
body: [
|
||||
'Wenn Fachseite und Technik denselben Ansprechpartner brauchen und die Datenhaltung Teil der Produktarbeit ist, nicht ein Nachgedanke.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'Vom Anliegen zum Betrieb',
|
||||
body: [
|
||||
'Die Zusammenarbeit folgt der Reihenfolge Lage, Diagnose, Umsetzung, Betrieb oder Übergabe.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Fall Innofocus / reifen.com', routeId: 'projects', fragment: 'innofocus' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Ein Produkt braucht eine belastbare Kette aus Domäne, Daten, API und Oberfläche — oft mit gewachsener Datenhaltung und bestehenden Anmeldediensten.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Zuerst die fachliche Grenze, dann das Datenmodell, dann die Schnittstellen. Domain-driven Design und Tests machen die Annahmen sichtbar, bevor Code in die Breite geht.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Umsetzung in Java und Spring Boot oder in C# und .NET, mit Angular auf der Oberfläche, REST-APIs, SQL- bzw. T-SQL-Datenarbeit und Anmeldung über Keycloak bzw. OAuth 2.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Der Stand wird so übergeben, dass Tests, Datenarbeit und Betrieb nachvollziehbar bleiben — nicht als undokumentierter Zwischenstand.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'innofocus',
|
||||
referenceHeadline: 'Passender öffentlicher Fall',
|
||||
referenceNote:
|
||||
'Die ERP-Datenmigration für Innofocus / reifen.com gehört in diesen Bereich, weil sie Produkt- und Datenarbeit mit T-SQL, .NET, Angular und Keycloak ist.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine Softwareanfrage reicht ein kurzes Briefing zur Lage. Daraus wird eine lokale E-Mail, ohne Versand an diesen Server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_HARDWARE_DE: ServicePageCopy = {
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
title: 'Hardware und Netzwerk | Antonio Ledebuhr',
|
||||
description:
|
||||
'Netz- und Serverstrukturen, Mitarbeitergeräte, Microsoft 365 sowie Server und Virtualisierung vor Ort — kostengünstig und wartbar.',
|
||||
hero: {
|
||||
headline: 'Infrastruktur, die sich im Alltag tragen lässt',
|
||||
proof:
|
||||
'Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, Server und Virtualisierung vor Ort. Ausgelegt auf Kosten und Wartbarkeit.',
|
||||
playfulLine: 'Von der ersten Bohne bis zum stabilen Betrieb',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was dieser Bereich abdeckt',
|
||||
body: [
|
||||
'Nicht nur ein einzelnes Gerät, sondern die Kette aus Netz, Server, Arbeitsplatz und den Diensten, mit denen das Unternehmen täglich arbeitet.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'Wann das passt',
|
||||
body: [
|
||||
'Wenn interne IT und der öffentliche Auftritt dieselbe Verantwortung brauchen und die Infrastruktur klein, klar und bezahlbar bleiben soll.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'Vom Anliegen zum Betrieb',
|
||||
body: [
|
||||
'Die Zusammenarbeit folgt der Reihenfolge Lage, Diagnose, Umsetzung, Betrieb oder Übergabe.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Gewachsene Netze, einzelne Server und verteilte Geräte machen den Alltag teurer, als er sein müsste — besonders wenn Shop und interne IT getrennt gedacht werden.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Welche Wege müssen stabil sein, welche Dienste gehören zusammen, und wo reichen kostengünstige, wartbare Bausteine statt zusätzlicher Schichten?',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Netz- und Serverstrukturen, Mitarbeitergeräte, Microsoft 365, Server und Virtualisierung vor Ort — so geschnitten, dass Betrieb und Änderung im Haus bleiben.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Die Infrastruktur bleibt im Alltag bedienbar. Übergabe heißt: nachvollziehbare Strukturen, keine undokumentierte Sonderlocke.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'roesterei',
|
||||
referenceHeadline: 'Passender öffentlicher Fall',
|
||||
referenceNote:
|
||||
'Die Gesamtverantwortung für die Technik der Rösterei Tangermünde gehört in diesen Bereich, weil sie vom Netz bis zum Shop reicht.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für Infrastrukturfragen hilft eine kurze Lagebeschreibung. Das Briefing bleibt im Browser und öffnet nur das lokale E-Mail-Programm.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_CLUSTERS_DE: ServicePageCopy = {
|
||||
routeId: 'servicesClusters',
|
||||
title: 'Clusterbetrieb | Antonio Ledebuhr',
|
||||
description:
|
||||
'Docker und Kubernetes on-premise und auf Azure AKS, Migration von Hosts und PaaS, GitLab CI/CD, Azure DevOps und GitOps mit Argo CD.',
|
||||
hero: {
|
||||
headline: 'Von einzelnen Hosts und PaaS auf Cluster',
|
||||
proof:
|
||||
'Docker und Kubernetes on-premise und in der Cloud (Azure AKS), Pipelines mit GitLab CI/CD und Azure DevOps, GitOps mit Argo CD, plus Betrieb und Übergabe.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was dieser Bereich abdeckt',
|
||||
body: [
|
||||
'Die Migration von einzelnen Hosts oder PaaS-Diensten auf ein Cluster, inklusive der Pipelines, mit denen der Stand nachvollziehbar ausgerollt wird.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'Wann das passt',
|
||||
body: [
|
||||
'Wenn Entwicklungs-, QS- und Produktivumgebungen dieselbe Betriebsform brauchen und der Weg dorthin dokumentiert bleiben soll.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'Vom Anliegen zum Betrieb',
|
||||
body: [
|
||||
'Die Zusammenarbeit folgt der Reihenfolge Lage, Diagnose, Umsetzung, Betrieb oder Übergabe.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Fall HDI Specialty', routeId: 'projects', fragment: 'hdi' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Anwendungen laufen auf einzelnen Hosts oder auf PaaS. Umgebungen weichen voneinander ab, Ausrollen und Betrieb hängen an Handgriffen.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Welche Last muss das Cluster tragen, welche Umgebung muss identisch bleiben, und welcher GitOps- oder Pipeline-Weg passt zur bestehenden Toolkette?',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Docker und Kubernetes on-premise oder auf Azure AKS, GitLab CI/CD oder Azure DevOps, bei Bedarf GitOps mit Argo CD.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Betrieb und Übergabe gehören zusammen: der Clusterstand soll sich aus dem Repository erklären, nicht aus einem einzelnen Rechner.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'hdi',
|
||||
referenceHeadline: 'Passender öffentlicher Fall',
|
||||
referenceNote:
|
||||
'Die Migration der Umgebungen von HDI Specialty von Azure App Services auf Azure AKS gehört in diesen Bereich, weil sie den Weg auf ein Cluster zeigt.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für Clusterfragen: welche Umgebungen umziehen sollen und worauf sie heute laufen. Das Briefing erzeugt nur eine lokale E-Mail.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_AI_DE: ServicePageCopy = {
|
||||
routeId: 'servicesAi',
|
||||
title: 'KI-Integration | Antonio Ledebuhr',
|
||||
description:
|
||||
'Zwei umgesetzte KI-Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine — von der Prozessanalyse bis zu lokalen Modellen — werden je Auftrag geprüft.',
|
||||
hero: {
|
||||
headline: 'KI-Anbindung, die am konkreten Auftrag hängt',
|
||||
proof:
|
||||
'Umgesetzt sind ein Werkzeug zur Kategorisierung von Kundenbewertungen und ein Assistent für die Shop-Verwaltung, beide bei der Rösterei Tangermünde. Alles andere wird je Auftrag geschnitten und geprüft.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'Was öffentlich als umgesetzt gilt',
|
||||
body: [
|
||||
'Nur die beiden Werkzeuge der Rösterei Tangermünde sind als gelieferte Arbeit dargestellt: die Kategorisierung von Kundenbewertungen und der Assistent für die Shop-Verwaltung.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'offer-boundary',
|
||||
headline: 'Was Angebot bleibt',
|
||||
body: [
|
||||
'Prozessanalyse, externe Modelle und APIs, lokale Modelle, RAG (Abruf angereicherter Texte) mit Datenschutzfokus, Mensch in der Schleife und Mehr-Agenten-Abläufe sind Angebote. Sie werden je Auftrag geschnitten und geprüft, nicht als allgemeine Kundenerfahrung dargestellt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'Vom Anliegen zum Betrieb',
|
||||
body: [
|
||||
'Die Zusammenarbeit folgt der Reihenfolge Lage, Diagnose, Umsetzung, Betrieb oder Übergabe.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||
{ label: 'Projekt anfragen', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Ein interner Ablauf frisst Zeit, etwa das Sortieren von Bewertungen oder wiederkehrende Schritte in der Shop-Verwaltung. Ob ein Modell hilft, hängt am konkreten Prozess.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Zuerst der Ablauf, dann die Datengrenze, dann die Frage, ob ein vorhandenes Modell, eine API oder ein lokaler Lauf überhaupt passt. Das wird je Auftrag geprüft.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Wo ein Auftrag das hergibt, entsteht ein schmales Werkzeug in der bestehenden Kette — wie die beiden umgesetzten Werkzeuge der Rösterei, angebunden an die interne IT.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Das Werkzeug bleibt im internen Werkzeugkasten. Übergabe heißt nachvollziehbare Anbindung, nicht ein undokumentierter Chat neben dem Shop.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'roesterei',
|
||||
referenceHeadline: 'Passender öffentlicher Fall',
|
||||
referenceNote:
|
||||
'Die internen KI-Werkzeuge der Rösterei Tangermünde für Bewertungen und Shop-Verwaltung gehören in diesen Bereich, weil sie die umgesetzte KI-Arbeit sind.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine KI-Anfrage: welcher Ablauf entlastet werden soll und welche Daten im Haus bleiben müssen. Jeder Baustein wird am Auftrag geprüft.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'review-categorization',
|
||||
title: 'Kategorisierung von Kundenbewertungen',
|
||||
body: 'Ein KI-Werkzeug, das Kundenbewertungen kategorisiert. Umgesetzt für die Rösterei Tangermünde und in deren interne Werkzeugkette eingebunden.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'shop-management-assistant',
|
||||
title: 'Assistent für die Shop-Verwaltung',
|
||||
body: 'Ein KI-Werkzeug für die schnelle Verwaltung des neuen Shops, vergleichbar mit Shopify Sidekick und in die interne Werkzeugkette eingebunden. Umgesetzt für die Rösterei Tangermünde.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'process-analysis',
|
||||
title: 'Prozessanalyse vor dem Modell',
|
||||
body: 'Gemeinsames Zuschneiden des Ablaufs, bevor überhaupt ein Modell ins Spiel kommt. Umfang und Nutzen werden am konkreten Auftrag geprüft, nicht aus anderen Projekten übertragen.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'external-models',
|
||||
title: 'Externe Modelle und APIs',
|
||||
body: 'Anbindung eines externen Modells oder einer API an einen bestehenden Ablauf. Ob das zum Auftrag passt, wird je Auftrag geprüft und begrenzt.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'local-llms',
|
||||
title: 'Lokale Sprachmodelle',
|
||||
body: 'Prüfung, ob ein lokal betriebenes Modell für den jeweiligen Auftrag tragfähig ist. Das ist ein Angebot zur Klärung vor Ort, keine Darstellung allgemeiner Produktionserfahrung.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'rag-privacy',
|
||||
title: 'RAG mit Datenschutzgrenze',
|
||||
body: 'Wenn interne Texte ein Modell stützen sollen, wird RAG — der Abruf angereicherter Texte — samt Datenschutzgrenze am konkreten Bestand entworfen. Der Zuschnitt gilt nur für diesen Auftrag und wird mit der Fachseite geprüft.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'human-in-the-loop',
|
||||
title: 'Mensch in der Schleife',
|
||||
body: 'Ein Vorschlag, an welchen Stellen eine Person Freigabe oder Korrektur behält. Die Schleife wird je Auftrag gelegt und mit den Beteiligten geprüft, nicht als Standardpaket verkauft.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'multi-agent',
|
||||
title: 'Mehrere Agenten in einem Ablauf',
|
||||
body: 'Wenn mehrere spezialisierte Schritte nacheinander laufen sollen, wird das Zusammenspiel am Auftrag skizziert und geprüft. Es ist ein Angebot, kein Hinweis auf umgesetzte Kundenprojekte dieser Art.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
};
|
||||
52
src/app/core/content/de/site.ts
Normal file
52
src/app/core/content/de/site.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_DE } from './cases';
|
||||
import { HOME_DE } from './home';
|
||||
import {
|
||||
ABOUT_DE,
|
||||
CONTACT_DE,
|
||||
IMPRINT_DE,
|
||||
NOT_FOUND_DE,
|
||||
PRIVACY_DE,
|
||||
PROJECTS_DE,
|
||||
STACK_DE,
|
||||
} from './pages';
|
||||
import {
|
||||
SERVICES_AI_DE,
|
||||
SERVICES_CLUSTERS_DE,
|
||||
SERVICES_HARDWARE_DE,
|
||||
SERVICES_OVERVIEW_DE,
|
||||
SERVICES_SOFTWARE_DE,
|
||||
} from './services';
|
||||
|
||||
export const SITE_CONTENT_DE: SiteContent = {
|
||||
home: HOME_DE,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
servicesClusters: SERVICES_CLUSTERS_DE,
|
||||
servicesAi: SERVICES_AI_DE,
|
||||
},
|
||||
cases: CASES_DE,
|
||||
contact: CONTACT_DE,
|
||||
projects: PROJECTS_DE,
|
||||
stack: STACK_DE,
|
||||
legal: {
|
||||
imprint: IMPRINT_DE,
|
||||
privacy: PRIVACY_DE,
|
||||
},
|
||||
pages: {
|
||||
home: HOME_DE,
|
||||
services: SERVICES_OVERVIEW_DE,
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
servicesClusters: SERVICES_CLUSTERS_DE,
|
||||
servicesAi: SERVICES_AI_DE,
|
||||
projects: PROJECTS_DE,
|
||||
stack: STACK_DE,
|
||||
about: ABOUT_DE,
|
||||
contact: CONTACT_DE,
|
||||
imprint: IMPRINT_DE,
|
||||
privacy: PRIVACY_DE,
|
||||
notFound: NOT_FOUND_DE,
|
||||
},
|
||||
};
|
||||
180
src/app/core/content/en/cases.ts
Normal file
180
src/app/core/content/en/cases.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { type CaseStudyCopy } from '../content.contracts';
|
||||
|
||||
export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
|
||||
innofocus: {
|
||||
id: 'innofocus',
|
||||
client: 'Innofocus / reifen.com',
|
||||
role: 'Senior backend and database engineer',
|
||||
period: 'since 09/2025, ongoing',
|
||||
domain: 'eCommerce / ERP',
|
||||
headline: 'Normalised ERP data migration inside Microsoft SQL Server',
|
||||
summary:
|
||||
'Replacement of the existing ERP with a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server.',
|
||||
responsibility:
|
||||
'Sole responsibility: the migration team is one person. As the only freelancer he is in direct contact with the end customer.',
|
||||
situation:
|
||||
'The existing ERP is being replaced by a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server. The legacy structure is not normalised; the target structure is. The migration is therefore largely data cleansing, normalisation and documentation for an auditor. The migration had previously been run by another service provider and was taken over.',
|
||||
approach: [
|
||||
'Extensive T-SQL stored procedures for transformation, normalisation and cleansing.',
|
||||
'Selective work on the application itself with C#, .NET and Angular.',
|
||||
'Sign-in through Keycloak with OAuth 2 / OIDC.',
|
||||
'Operation on Kubernetes, GitOps deployments via Argo CD, Git and pipelines on Azure DevOps.',
|
||||
],
|
||||
outcome: [
|
||||
'Migration run time is about 90 minutes instead of around eight hours, with a larger feature scope and higher data quality.',
|
||||
'The target structure is normalised and documented for the auditor.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → about 90 min',
|
||||
label: 'data-migration run time at Innofocus / reifen.com',
|
||||
note: 'with a larger feature scope and higher data quality',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
stack: [
|
||||
'Microsoft SQL Server',
|
||||
'T-SQL',
|
||||
'C#',
|
||||
'.NET',
|
||||
'Angular',
|
||||
'Keycloak',
|
||||
'OAuth 2 / OIDC',
|
||||
'Kubernetes',
|
||||
'Argo CD',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['eCommerce', 'ERP', 'data migration', 'SQL'],
|
||||
linkLabel: 'Read the case: Innofocus / reifen.com',
|
||||
},
|
||||
roesterei: {
|
||||
id: 'roesterei',
|
||||
client: 'Rösterei Tangermünde',
|
||||
role: 'Full technical responsibility for the company',
|
||||
period: 'since 10/2023, ongoing',
|
||||
domain: 'eCommerce and internal IT',
|
||||
headline: 'Shop-floor IT, storefront and internal tools under one roof',
|
||||
summary:
|
||||
'The original goal was a new shop. The scope grew far beyond that and now covers the company’s technology as a whole.',
|
||||
responsibility:
|
||||
'Full responsibility for the technology: network structures, server structures, employee devices, management SaaS such as Microsoft 365, the new online shop and new marketing.',
|
||||
situation:
|
||||
'The roastery first needed a new shop. That became responsibility for the network, servers, devices, Microsoft 365, the shop and marketing. Requirements come directly from non-technical stakeholders; brand identity is shaped together with a designer.',
|
||||
approach: [
|
||||
'Contemporary and cost-effective infrastructure instead of extra complexity.',
|
||||
'Shop version 1: Shopify with a custom theme and custom components in Liquid.',
|
||||
'Shop version 2: Liquid and Hydrogen, with own services integrated.',
|
||||
'Internal tools for cost reduction and autonomy: a generator for vector graphics used in advertising material, an AI tool that categorises customer reviews, and an AI tool for fast management of the new shop, comparable to Shopify Sidekick and wired into the internal tooling.',
|
||||
],
|
||||
outcome: [
|
||||
'The company’s technology sits with one owner, from the network to the shop.',
|
||||
'Two AI tools are in use in the roastery’s internal operations: review categorisation and the shop assistant.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Shopify',
|
||||
'Liquid',
|
||||
'Hydrogen',
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'SCSS',
|
||||
'Java Spring WebFlux',
|
||||
'Java OpenAI library',
|
||||
'MariaDB',
|
||||
'Docker',
|
||||
'Kubernetes',
|
||||
'Proxmox',
|
||||
'GitLab CE',
|
||||
'Microsoft 365',
|
||||
],
|
||||
tags: ['eCommerce', 'internal IT', 'Shopify', 'AI tools'],
|
||||
linkLabel: 'Read the case: Rösterei Tangermünde',
|
||||
transparencyNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||
},
|
||||
myspa: {
|
||||
id: 'myspa',
|
||||
client: 'Aracom IT Services / MySpa',
|
||||
role: 'Lead backend / DevOps',
|
||||
period: '03/2024 – 09/2024',
|
||||
domain: 'IoT',
|
||||
headline: 'A backend between the house, payments and room technology',
|
||||
summary:
|
||||
'Replacement of legacy software with new custom software. The backend is the interface between the website, the internal admin tool, the payment terminal, lockers, smart devices and touch panels.',
|
||||
responsibility:
|
||||
'Took over leadership of the backend team (4 people) and the DevOps team (2 people) until the software launch. Direct customer contact for requirements.',
|
||||
situation:
|
||||
'The existing software was to be replaced by a new custom system. The backend is the interface between the website, the internal admin tool, the payment terminal, lockers, smart devices (lights, TVs) and touch panels for placing orders and controlling room technology.',
|
||||
approach: [
|
||||
'Backend with C# and .NET 8, a RESTful API, and Entity Framework 8 in a data-first approach.',
|
||||
'On-prem MySQL server administration.',
|
||||
'Test-driven development with unit and integration tests (xUnit).',
|
||||
'OAuth 2 via an identity provider framework, plus MQTT and Hangfire.',
|
||||
'Migration of development, QA and production systems from on-prem services to a Docker cluster.',
|
||||
'CI/CD pipelines with GitLab EE, work in Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'The backend connects the channels listed above through to the software launch.',
|
||||
'Development, QA and production systems run on the Docker cluster.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'C#',
|
||||
'.NET 8',
|
||||
'ASP.NET Core',
|
||||
'Entity Framework 8',
|
||||
'xUnit',
|
||||
'MySQL',
|
||||
'OAuth 2',
|
||||
'MQTT',
|
||||
'Hangfire',
|
||||
'Docker',
|
||||
'GitLab EE',
|
||||
],
|
||||
tags: ['IoT', 'backend', 'DevOps', 'Docker'],
|
||||
linkLabel: 'Read the case: Aracom IT Services / MySpa',
|
||||
},
|
||||
hdi: {
|
||||
id: 'hdi',
|
||||
client: 'HDI Specialty',
|
||||
role: 'Senior fullstack / DevOps',
|
||||
period: '05/2023 – 01/2024',
|
||||
domain: 'Insurance',
|
||||
headline: 'Exposure management for simulations and BaFin reports',
|
||||
summary:
|
||||
'Software that automates simulation sequences to determine specific risks, evaluates simulations stochastically, collects policies centrally and produces evaluations of all policies for BaFin.',
|
||||
responsibility:
|
||||
'Senior fullstack and DevOps work on the exposure-management software, including the migration of the environments onto Azure AKS.',
|
||||
situation:
|
||||
'HDI Specialty needs software for exposure management: simulation sequences should determine specific risks, be evaluated stochastically, collect policies centrally and produce evaluations of all policies for BaFin.',
|
||||
approach: [
|
||||
'Angular single-page application with TypeScript, HTML, SCSS/CSS, Ngrx Store and Ngx Pipes.',
|
||||
'Backend with C# and .NET, a RESTful API and Entity Framework.',
|
||||
'Test-driven development with unit and integration tests (xUnit).',
|
||||
'Azure SQL Server administration.',
|
||||
'Authentication and authorisation through Okta with OAuth 2.',
|
||||
'CI/CD pipelines and project management with Azure DevOps, work in Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Development, QA and production systems were migrated from Azure App Services to a Kubernetes cluster on Azure AKS.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'Ngrx',
|
||||
'Ngx Pipes',
|
||||
'C#',
|
||||
'.NET',
|
||||
'Entity Framework',
|
||||
'xUnit',
|
||||
'Azure SQL Server',
|
||||
'Okta',
|
||||
'OAuth 2',
|
||||
'Azure AKS',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure'],
|
||||
linkLabel: 'Read the case: HDI Specialty',
|
||||
},
|
||||
};
|
||||
99
src/app/core/content/en/home.ts
Normal file
99
src/app/core/content/en/home.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type HomePageCopy } from '../content.contracts';
|
||||
|
||||
export const HOME_EN: HomePageCopy = {
|
||||
routeId: 'home',
|
||||
title: 'Home | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack and DevOps engineer in Tangermünde: about seven years of web application work, direct customer contact and team responsibility, freelance since 04/2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack and DevOps engineer based in Tangermünde',
|
||||
proof:
|
||||
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
|
||||
playfulLine: 'Full-stack, full throttle',
|
||||
},
|
||||
profile: [
|
||||
'About seven years of professional experience building and operating web applications.',
|
||||
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
|
||||
'Freelance since 04/2023, based in Tangermünde.',
|
||||
'Home ground is Java with Spring; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'about 7 years',
|
||||
label: 'professional experience with web applications',
|
||||
},
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → about 90 min',
|
||||
label: 'data-migration run time at Innofocus / reifen.com',
|
||||
note: 'with a larger feature scope and higher data quality',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'led the backend team and the DevOps team through the MySpa launch',
|
||||
caseId: 'myspa',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
{
|
||||
id: 'recruiters',
|
||||
headline: 'For recruiters',
|
||||
body: 'A short path through experience, selected cases, the public stack and the curriculum vitae.',
|
||||
bullets: [
|
||||
'About seven years of fullstack and DevOps work with direct customer contact and team responsibility.',
|
||||
'Four public cases across eCommerce, in-house IT, IoT and insurance.',
|
||||
'Home ground Java and Spring, plus C#/.NET, Angular, SQL, Docker and Kubernetes.',
|
||||
'Curriculum vitae as a PDF download.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Selected projects', routeId: 'projects' },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{
|
||||
label: 'Curriculum vitae as PDF',
|
||||
href: SITE_CONFIG.cvAssetPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'companies',
|
||||
headline: 'For companies',
|
||||
body: 'Four service areas, from the first workshop through to operations — and a short briefing for an enquiry.',
|
||||
bullets: [
|
||||
'Software: fullstack product work with Java, .NET, Angular and SQL data work.',
|
||||
'Hardware and network: servers, devices, Microsoft 365 and maintainable infrastructure.',
|
||||
'Clusters: Docker and Kubernetes on-premise and on Azure AKS, including pipelines.',
|
||||
'AI integration: two delivered tools at Rösterei Tangermünde, further building blocks as an offer.',
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Browse services', routeId: 'services' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Thirty seconds',
|
||||
body: [
|
||||
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. This site shows a curated selection of stations; the full curriculum vitae is available as a PDF.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Selected cases',
|
||||
body: [
|
||||
'The four public cases cover eCommerce and ERP, shop-floor IT, IoT and insurance. Each case is written in full on the projects page.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Services', routeId: 'services' },
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
365
src/app/core/content/en/pages.ts
Normal file
365
src/app/core/content/en/pages.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import {
|
||||
type ContactPageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type StackPageCopy,
|
||||
} from '../content.contracts';
|
||||
|
||||
export const PROJECTS_EN: ProjectsPageCopy = {
|
||||
routeId: 'projects',
|
||||
title: 'Projects | Antonio Ledebuhr',
|
||||
description:
|
||||
'Four public cases: Innofocus / reifen.com, Rösterei Tangermünde, MySpa and HDI Specialty. A deliberately narrow selection; the full curriculum vitae is available as a download.',
|
||||
hero: {
|
||||
headline: 'Four public cases, deliberately chosen',
|
||||
proof:
|
||||
'The site shows a curated selection. The full curriculum vitae is available as a PDF download.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'selection',
|
||||
headline: 'Why only these four',
|
||||
body: [
|
||||
'The public record has four cases: Innofocus / reifen.com, Rösterei Tangermünde, MySpa and HDI Specialty. Further stations belong in the curriculum vitae, not on these pages.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'What each case documents',
|
||||
body: [
|
||||
'Each case documents the engagement, the role, the period, the situation, the approach and the outcome. Figures appear only where they are verified.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
situation: 'Situation',
|
||||
approach: 'Approach',
|
||||
outcome: 'Outcome',
|
||||
stack: 'Stack',
|
||||
tags: 'Tags',
|
||||
},
|
||||
};
|
||||
|
||||
export const STACK_EN: StackPageCopy = {
|
||||
routeId: 'stack',
|
||||
title: 'Technology stack | Antonio Ledebuhr',
|
||||
description:
|
||||
'The public stack in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
|
||||
hero: {
|
||||
headline: 'The stack that appears on these pages',
|
||||
proof:
|
||||
'The groups belong to product work in Java and Spring and in C# and .NET, to SQL Server data work, to containers and clusters, and to servers on site.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'reading',
|
||||
headline: 'A selection, not a ranking',
|
||||
body: ['The overview is a selection from the public work, not a ranking.'],
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
headline: 'Where the groups show up in the work',
|
||||
body: [
|
||||
'Product work in Java and Spring and in C# and .NET, with Angular on the front end. SQL Server and T-SQL data work. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in GitLab CI/CD and Azure DevOps, GitOps with Argo CD. Servers and virtualisation on site.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'About', routeId: 'about' },
|
||||
],
|
||||
groups: [
|
||||
{ id: 'programming', title: 'Programming' },
|
||||
{ id: 'db', title: 'Databases' },
|
||||
{ id: 'devops', title: 'DevOps' },
|
||||
{ id: 'os', title: 'Operating Systems' },
|
||||
{ id: 'iac', title: 'Infrastructure as Code' },
|
||||
{ id: 'hyperviser', title: 'Hypervisors' },
|
||||
{ id: 'tools', title: 'Tools' },
|
||||
],
|
||||
};
|
||||
|
||||
export const ABOUT_EN: PageCopy = {
|
||||
routeId: 'about',
|
||||
title: 'About | Antonio Ledebuhr',
|
||||
description:
|
||||
'Antonio Ledebuhr, fullstack and DevOps engineer in Tangermünde. Freelance since 04/2023, German native, English at negotiation level.',
|
||||
hero: {
|
||||
headline: 'Antonio Ledebuhr, fullstack and DevOps engineer',
|
||||
proof:
|
||||
'Based in Tangermünde. Freelance since 04/2023. German native, English at negotiation level.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Professional profile',
|
||||
body: [
|
||||
'About seven years of professional experience building and operating web applications. The work covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
|
||||
'Home ground is Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Alongside that sit C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) and Angular (TypeScript, RxJS-era SPA work, Ngrx, SCSS).',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'working',
|
||||
headline: 'Way of working',
|
||||
body: [
|
||||
'Clean code, architecture that can be lived with, domain-driven design and test-driven development. Landing in a new domain is part of the work, not a side task.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-and-language',
|
||||
headline: 'Customer contact, teams and languages',
|
||||
body: [
|
||||
'Direct customer contact and team responsibility are part of the work so far, not a later add-on.',
|
||||
'German is the native language. English is at negotiation level.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'accent',
|
||||
headline: 'Roastery and torque',
|
||||
body: [
|
||||
'The roastery work remains the personal accent: brewing and torque rather than idle. Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
|
||||
export const CONTACT_EN: ContactPageCopy = {
|
||||
routeId: 'contact',
|
||||
title: 'Contact | Antonio Ledebuhr',
|
||||
description:
|
||||
'A short project briefing in the browser. It only assembles a local email to info@antoniolede.de; nothing is sent to this server.',
|
||||
hero: {
|
||||
headline: 'A short briefing, then the local mail program',
|
||||
proof:
|
||||
'The fields stay in the browser. The button only opens a prefilled message to info@antoniolede.de.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'how',
|
||||
headline: 'What happens to the answers',
|
||||
body: [
|
||||
'Nothing is sent to this server. The filled fields become a mailto message that opens the local mail program.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'direct',
|
||||
headline: 'Write directly',
|
||||
body: [
|
||||
'Anyone who prefers to skip the briefing can write to info@antoniolede.de. A calendar link is not published at the moment.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [],
|
||||
fields: [
|
||||
{ id: 'name', control: 'text', label: 'Name', required: true },
|
||||
{ id: 'email', control: 'email', label: 'Email', required: true },
|
||||
{ id: 'company', control: 'text', label: 'Company', hint: 'optional', required: false },
|
||||
{
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Type of work',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware and network' },
|
||||
{ value: 'clusters', label: 'Clusters' },
|
||||
{ value: 'ai', label: 'AI integration' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'situation',
|
||||
control: 'textarea',
|
||||
label: 'Situation',
|
||||
hint: 'What is in front of you, and where should the work start?',
|
||||
required: true,
|
||||
},
|
||||
{ id: 'timeframe', control: 'text', label: 'Timeframe', hint: 'optional', required: false },
|
||||
],
|
||||
mailSubject: 'Project enquiry via antoniolede.de',
|
||||
mailIntro: 'Short briefing from the website:',
|
||||
mailSignature: 'Sent via the contact briefing on antoniolede.de.',
|
||||
submitLabel: 'Open the mail program',
|
||||
incompleteHint: 'Still needed:',
|
||||
requiredMarkerLabel: 'Required field',
|
||||
directEmailLabel: 'Write directly to info@antoniolede.de',
|
||||
calendarLabel: 'Find a time',
|
||||
noBackendNote:
|
||||
'There is no form backend. Nothing is transmitted to this server. The button only opens the local mail program.',
|
||||
};
|
||||
|
||||
export const IMPRINT_EN: LegalPageCopy = {
|
||||
routeId: 'imprint',
|
||||
title: 'Legal notice | Antonio Ledebuhr',
|
||||
description:
|
||||
'Legal notice for Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde. The text is unreviewed and must be checked by the site owner before publication.',
|
||||
hero: {
|
||||
headline: 'Legal notice',
|
||||
proof:
|
||||
'Name, address and email are taken from the curriculum vitae. Further required details are left blank on purpose.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What is on this page',
|
||||
body: [
|
||||
'This text names the person, the address, the email address and responsibility for the content. It is not a finished legal notice.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
headline: 'What is missing',
|
||||
body: [
|
||||
'A VAT ID, register details, a supervisory authority, professional-liability insurance and a telephone number are not published here because they are not verified for this site.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [{ label: 'Privacy', routeId: 'privacy' }],
|
||||
reviewNotice:
|
||||
'Warning: this legal text is unreviewed. The site owner must check it before any publication and complete the open items below.',
|
||||
reviewTodos: [
|
||||
'VAT identification number, if one exists',
|
||||
'Register court and register number, if an entry exists',
|
||||
'Supervisory authority, if one applies',
|
||||
'Professional-liability insurance, if one exists',
|
||||
'Telephone number, if it should be published',
|
||||
'Hosting provider and its data processing',
|
||||
],
|
||||
legalSections: [
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'Provider',
|
||||
body: [
|
||||
'Antonio Ledebuhr',
|
||||
'Kirchstr. 19',
|
||||
'39590 Tangermünde',
|
||||
'Germany',
|
||||
'Email: info@antoniolede.de',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'responsibility',
|
||||
title: 'Responsible for the content',
|
||||
body: [
|
||||
'Responsible for the content of these pages is Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'completeness',
|
||||
title: 'No claim of completeness',
|
||||
body: [
|
||||
'This page does not claim legal completeness. Open items sit in the review list above and must be completed by the site owner before publication.',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const PRIVACY_EN: LegalPageCopy = {
|
||||
routeId: 'privacy',
|
||||
title: 'Privacy | Antonio Ledebuhr',
|
||||
description:
|
||||
'Notes on processing for this static, server-rendered site without its own backend. The text is unreviewed and must be completed before publication.',
|
||||
hero: {
|
||||
headline: 'Privacy',
|
||||
proof:
|
||||
'The site has no backend of its own. The contact briefing stays in the browser and only assembles a mailto message.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What this text covers',
|
||||
body: [
|
||||
'It describes the public site as far as that is verified here. It is not a finished privacy statement.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
headline: 'What the owner still has to add',
|
||||
body: [
|
||||
'The hosting provider, its processing and further legally required items sit in the review list and are left blank here on purpose.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [{ label: 'Legal notice', routeId: 'imprint' }],
|
||||
reviewNotice:
|
||||
'Warning: this legal text is unreviewed. The site owner must check it before any publication and complete the open items below.',
|
||||
reviewTodos: [
|
||||
'Hosting provider and its commissioned processing',
|
||||
'Server location and any sub-processors in use',
|
||||
'A contact path for privacy requests, if email is not enough',
|
||||
'Further legally required items once they are known',
|
||||
],
|
||||
legalSections: [
|
||||
{
|
||||
id: 'controller',
|
||||
title: 'Controller',
|
||||
body: [
|
||||
'Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany, email: info@antoniolede.de.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'site-nature',
|
||||
title: 'Nature of this site',
|
||||
body: [
|
||||
'This site is a static, server-rendered site without its own backend. There is no user account and no server-side form intake on this domain.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-briefing',
|
||||
title: 'Contact briefing',
|
||||
body: [
|
||||
'The contact briefing runs entirely in the browser. It only assembles a mailto link. No form data reaches this site.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
title: 'Email correspondence',
|
||||
body: [
|
||||
'When a message is sent to info@antoniolede.de, the correspondence is processed for the purpose of the enquiry. No other use is described here.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rights',
|
||||
title: 'Rights of data subjects',
|
||||
body: [
|
||||
'Data subjects have the general rights of access, rectification, erasure, restriction of processing, objection and complaint to a supervisory authority. How those rights are exercised depends on the specific matter.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hosting',
|
||||
title: 'Hosting',
|
||||
body: [
|
||||
'The hosting provider and its processing still have to be completed by the site owner. No provider is named here.',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const NOT_FOUND_EN: PageCopy = {
|
||||
routeId: 'notFound',
|
||||
title: 'Page not found | Antonio Ledebuhr',
|
||||
description:
|
||||
'This address does not match a public page. Continue via home, services, projects or contact.',
|
||||
hero: {
|
||||
headline: 'This page is not on the public site',
|
||||
proof:
|
||||
'The address does not match a public route. The four links below lead back into the site.',
|
||||
},
|
||||
sections: [],
|
||||
ctas: [
|
||||
{ label: 'Back to home', routeId: 'home' },
|
||||
{ label: 'Services', routeId: 'services' },
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
424
src/app/core/content/en/services.ts
Normal file
424
src/app/core/content/en/services.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
||||
|
||||
export const SERVICES_OVERVIEW_EN: PageCopy = {
|
||||
routeId: 'services',
|
||||
title: 'Services | Antonio Ledebuhr',
|
||||
description:
|
||||
'Four service areas covering product software, infrastructure, clusters and AI integration — from the first workshop through to operations, with direct customer contact.',
|
||||
hero: {
|
||||
headline: 'Four service areas, one counterpart',
|
||||
proof:
|
||||
'Software, hardware and network, clusters, and AI integration. The path runs from the situation through diagnosis and implementation into operations.',
|
||||
playfulLine: 'Systems that stay in gear',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'areas',
|
||||
headline: 'The four areas',
|
||||
body: [
|
||||
'Software covers fullstack product work with Java and Spring Boot, C# and .NET, Angular, REST APIs, domain-driven design, test-driven development, and SQL / T-SQL data work.',
|
||||
'Hardware and network covers network and server structures, employee devices, management SaaS such as Microsoft 365, and on-prem servers and virtualisation.',
|
||||
'Clusters covers Docker and Kubernetes on-premise and on Azure AKS, migration off single hosts and PaaS onto clusters, GitLab CI/CD and Azure DevOps, and GitOps with Argo CD.',
|
||||
'AI integration is the largest area: two delivered tools at Rösterei Tangermünde, and further building blocks that are scoped and validated per engagement.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'who',
|
||||
headline: 'Who this is for',
|
||||
body: [
|
||||
'For companies that want one counterpart from the first requirements workshop through to production — without an extra layer between the business side and the technology.',
|
||||
'For recruiters who want to read the public selection and the stack before opening the curriculum vitae.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headline: 'How an engagement runs',
|
||||
body: [
|
||||
'Situation first, then diagnosis, then implementation, then operations or handover. Each detail page follows that order and names a fitting public case.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Software', routeId: 'servicesSoftware' },
|
||||
{ label: 'Hardware and network', routeId: 'servicesHardwareNetwork' },
|
||||
{ label: 'Clusters', routeId: 'servicesClusters' },
|
||||
{ label: 'AI integration', routeId: 'servicesAi' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
|
||||
export const SERVICES_SOFTWARE_EN: ServicePageCopy = {
|
||||
routeId: 'servicesSoftware',
|
||||
title: 'Software engineering | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack product work with Java and Spring Boot, C# and .NET, Angular, REST APIs, domain-driven design, tests and SQL data work.',
|
||||
hero: {
|
||||
headline: 'Fullstack product work, from the brief to delivery',
|
||||
proof:
|
||||
'Java and Spring Boot, C# and .NET, Angular, REST APIs, domain-driven design, test-driven development and SQL / T-SQL data work. Sign-in with Keycloak and OAuth 2.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What this area covers',
|
||||
body: [
|
||||
'New product software and the reshaping of existing applications. The work runs from the domain through the API to the interface, including the data model and sign-in.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'When this fits',
|
||||
body: [
|
||||
'When the business side and the technology need the same counterpart, and data work is part of the product rather than an afterthought.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'From the brief to operations',
|
||||
body: [
|
||||
'The work follows the order situation, diagnosis, implementation, then operations or handover.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Innofocus / reifen.com case', routeId: 'projects', fragment: 'innofocus' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'A product needs a reliable chain from domain and data through the API to the interface — often with inherited data stores and existing identity services.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'Domain boundary first, then the data model, then the interfaces. Domain-driven design and tests make the assumptions visible before the code fans out.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Delivery in Java and Spring Boot or in C# and .NET, with Angular on the surface, REST APIs, SQL / T-SQL data work and sign-in through Keycloak or OAuth 2.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Operations and handover',
|
||||
body: [
|
||||
'The result is handed over so that tests, data work and operations stay readable — not as an undocumented snapshot.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'innofocus',
|
||||
referenceHeadline: 'A fitting public case',
|
||||
referenceNote:
|
||||
'The ERP data migration for Innofocus / reifen.com belongs here because it is product and data work with T-SQL, .NET, Angular and Keycloak.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Enquiry',
|
||||
body: [
|
||||
'A short briefing of the situation is enough for a software enquiry. It becomes a local email and is not sent to this server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_HARDWARE_EN: ServicePageCopy = {
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
title: 'Hardware and network | Antonio Ledebuhr',
|
||||
description:
|
||||
'Network and server structures, employee devices, Microsoft 365, plus on-prem servers and virtualisation — kept cost-effective and maintainable.',
|
||||
hero: {
|
||||
headline: 'Infrastructure that holds up on a Tuesday',
|
||||
proof:
|
||||
'Network and server structures, employee devices, management SaaS such as Microsoft 365, on-prem servers and virtualisation. Cut for cost and maintainability.',
|
||||
playfulLine: 'Freshly brewed automation',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What this area covers',
|
||||
body: [
|
||||
'Not a single box, but the chain of network, servers, workplaces and the services the company uses every day.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'When this fits',
|
||||
body: [
|
||||
'When in-house IT and the public storefront need the same owner, and the infrastructure should stay small, clear and affordable.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'From the brief to operations',
|
||||
body: [
|
||||
'The work follows the order situation, diagnosis, implementation, then operations or handover.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Grown networks, single servers and scattered devices make the week more expensive than it needs to be — especially when the shop and in-house IT are treated as separate worlds.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'Which paths have to stay up, which services belong together, and where a cost-effective, maintainable building block is enough?',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Network and server structures, employee devices, Microsoft 365, on-prem servers and virtualisation — cut so that operations and change stay in-house.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Operations and handover',
|
||||
body: [
|
||||
'The infrastructure stays operable in daily work. Handover means readable structures, not an undocumented exception.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'roesterei',
|
||||
referenceHeadline: 'A fitting public case',
|
||||
referenceNote:
|
||||
'Full technical responsibility for Rösterei Tangermünde belongs here because it runs from the network to the shop.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Enquiry',
|
||||
body: [
|
||||
'A short description of the current setup is enough. The briefing stays in the browser and only opens the local mail program.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_CLUSTERS_EN: ServicePageCopy = {
|
||||
routeId: 'servicesClusters',
|
||||
title: 'Cluster operations | Antonio Ledebuhr',
|
||||
description:
|
||||
'Docker and Kubernetes on-premise and on Azure AKS, migration off hosts and PaaS, GitLab CI/CD, Azure DevOps and GitOps with Argo CD.',
|
||||
hero: {
|
||||
headline: 'Off single hosts and PaaS, onto a cluster',
|
||||
proof:
|
||||
'Docker and Kubernetes on-premise and in the cloud (Azure AKS), pipelines with GitLab CI/CD and Azure DevOps, GitOps with Argo CD, plus operations and handover.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What this area covers',
|
||||
body: [
|
||||
'Moving from single hosts or PaaS services onto a cluster, including the pipelines that roll the same state out in a readable way.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fit',
|
||||
headline: 'When this fits',
|
||||
body: [
|
||||
'When development, QA and production need the same operating shape, and the path there should stay documented.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'From the brief to operations',
|
||||
body: [
|
||||
'The work follows the order situation, diagnosis, implementation, then operations or handover.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'HDI Specialty case', routeId: 'projects', fragment: 'hdi' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Applications run on single hosts or on PaaS. Environments drift, and rollout and operations still depend on hand work.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'What load the cluster must carry, which environments must stay alike, and which GitOps or pipeline path fits the existing toolchain.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Docker and Kubernetes on-premise or on Azure AKS, GitLab CI/CD or Azure DevOps, and GitOps with Argo CD where that fits.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Operations and handover',
|
||||
body: [
|
||||
'Operations and handover belong together: the cluster state should be explained by the repository, not by a single machine.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'hdi',
|
||||
referenceHeadline: 'A fitting public case',
|
||||
referenceNote:
|
||||
'The migration of the HDI Specialty environments from Azure App Services to Azure AKS belongs here because it is the move onto a cluster.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Enquiry',
|
||||
body: [
|
||||
'For a cluster enquiry: which environments should move and what they run on today. The briefing only creates a local email.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [],
|
||||
};
|
||||
|
||||
export const SERVICES_AI_EN: ServicePageCopy = {
|
||||
routeId: 'servicesAi',
|
||||
title: 'AI integration | Antonio Ledebuhr',
|
||||
description:
|
||||
'Two delivered AI tools at Rösterei Tangermünde. Further building blocks — from process analysis to local models — are validated per client.',
|
||||
hero: {
|
||||
headline: 'AI integration that stays tied to the brief',
|
||||
proof:
|
||||
'Delivered work is a review-categorisation tool and a shop-management assistant, both at Rösterei Tangermünde. Everything else is scoped and validated per engagement.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'scope',
|
||||
headline: 'What is presented as delivered',
|
||||
body: [
|
||||
'Only the two Rösterei Tangermünde tools are shown as delivered work: the categorisation of customer reviews and the assistant for shop management.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'offer-boundary',
|
||||
headline: 'What remains an offer',
|
||||
body: [
|
||||
'Process analysis, external models and APIs, local models, retrieval-augmented text with a privacy boundary, human-in-the-loop and multi-agent workflows are offers. They are scoped and validated per engagement, not presented as general client experience.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sequence',
|
||||
headline: 'From the brief to operations',
|
||||
body: [
|
||||
'The work follows the order situation, diagnosis, implementation, then operations or handover.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||
{ label: 'Start a project enquiry', routeId: 'contact' },
|
||||
],
|
||||
sequence: {
|
||||
situation: {
|
||||
id: 'situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'An internal process burns time, such as sorting reviews or repeating steps in shop management. Whether a model helps depends on that process.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'The process first, then the data boundary, then whether an existing model, an API or a local run even fits. That is validated per engagement.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Where the brief supports it, a narrow tool lands in the existing chain — as with the two delivered Rösterei tools, wired into the in-house IT.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'operation',
|
||||
headline: 'Operations and handover',
|
||||
body: [
|
||||
'The tool stays inside the internal toolbox. Handover means a readable integration, not an undocumented chat beside the shop.',
|
||||
],
|
||||
},
|
||||
referenceCaseId: 'roesterei',
|
||||
referenceHeadline: 'A fitting public case',
|
||||
referenceNote:
|
||||
'The in-house AI tools at Rösterei Tangermünde for reviews and shop management belong here because they are the delivered AI work.',
|
||||
inquiry: {
|
||||
id: 'inquiry',
|
||||
headline: 'Enquiry',
|
||||
body: [
|
||||
'For an AI enquiry: which process should get lighter, and which data has to stay in-house. Each building block is validated against that brief.',
|
||||
],
|
||||
},
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'review-categorization',
|
||||
title: 'Customer-review categorisation',
|
||||
body: 'An AI tool that categorises customer reviews. Delivered for Rösterei Tangermünde and wired into its internal tooling.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'shop-management-assistant',
|
||||
title: 'Shop-management assistant',
|
||||
body: 'An AI tool for fast management of the new shop, comparable to Shopify Sidekick and wired into the internal tooling. Delivered for Rösterei Tangermünde.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'process-analysis',
|
||||
title: 'Process analysis before the model',
|
||||
body: 'A joint cut of the process before a model enters the picture. Scope and usefulness are validated on the individual brief, not copied from other work.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'external-models',
|
||||
title: 'External models and APIs',
|
||||
body: 'Connecting an external model or API to an existing process. Whether that fits is scoped and validated for the individual client.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'local-llms',
|
||||
title: 'Local language models',
|
||||
body: 'A check of whether a locally operated model is viable for the individual brief. This is an offer to clarify on site, not a claim of general production experience.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'rag-privacy',
|
||||
title: 'Retrieval with a privacy boundary',
|
||||
body: 'If internal texts should ground a model, the retrieval path and the privacy boundary are designed against that corpus. The cut applies only to this engagement and is validated with the people who own the data.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'human-in-the-loop',
|
||||
title: 'A person in the loop',
|
||||
body: 'A proposal for where a person keeps approval or correction. The loop is placed per engagement and validated with the people involved; it is not a standard package.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'multi-agent',
|
||||
title: 'Several agents in one workflow',
|
||||
body: 'If several specialised steps should run in sequence, the composition is sketched and validated on the brief. It is an offer, not a pointer to delivered client work of that kind.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
};
|
||||
52
src/app/core/content/en/site.ts
Normal file
52
src/app/core/content/en/site.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_EN } from './cases';
|
||||
import { HOME_EN } from './home';
|
||||
import {
|
||||
ABOUT_EN,
|
||||
CONTACT_EN,
|
||||
IMPRINT_EN,
|
||||
NOT_FOUND_EN,
|
||||
PRIVACY_EN,
|
||||
PROJECTS_EN,
|
||||
STACK_EN,
|
||||
} from './pages';
|
||||
import {
|
||||
SERVICES_AI_EN,
|
||||
SERVICES_CLUSTERS_EN,
|
||||
SERVICES_HARDWARE_EN,
|
||||
SERVICES_OVERVIEW_EN,
|
||||
SERVICES_SOFTWARE_EN,
|
||||
} from './services';
|
||||
|
||||
export const SITE_CONTENT_EN: SiteContent = {
|
||||
home: HOME_EN,
|
||||
services: {
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
servicesClusters: SERVICES_CLUSTERS_EN,
|
||||
servicesAi: SERVICES_AI_EN,
|
||||
},
|
||||
cases: CASES_EN,
|
||||
contact: CONTACT_EN,
|
||||
projects: PROJECTS_EN,
|
||||
stack: STACK_EN,
|
||||
legal: {
|
||||
imprint: IMPRINT_EN,
|
||||
privacy: PRIVACY_EN,
|
||||
},
|
||||
pages: {
|
||||
home: HOME_EN,
|
||||
services: SERVICES_OVERVIEW_EN,
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
servicesClusters: SERVICES_CLUSTERS_EN,
|
||||
servicesAi: SERVICES_AI_EN,
|
||||
projects: PROJECTS_EN,
|
||||
stack: STACK_EN,
|
||||
about: ABOUT_EN,
|
||||
contact: CONTACT_EN,
|
||||
imprint: IMPRINT_EN,
|
||||
privacy: PRIVACY_EN,
|
||||
notFound: NOT_FOUND_EN,
|
||||
},
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
|
||||
import { type PageCopy, type SiteContent } from './content.contracts';
|
||||
|
||||
const PAGE_TITLES: Record<RouteId, Record<AppLocale, string>> = {
|
||||
home: { de: 'Startseite', en: 'Home' },
|
||||
services: { de: 'Leistungen', en: 'Services' },
|
||||
servicesSoftware: { de: 'Software', en: 'Software' },
|
||||
servicesHardwareNetwork: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
|
||||
servicesClusters: { de: 'Cluster', en: 'Clusters' },
|
||||
servicesAi: { de: 'KI-Integration', en: 'AI integration' },
|
||||
projects: { de: 'Projekte', en: 'Projects' },
|
||||
stack: { de: 'Stack', en: 'Stack' },
|
||||
about: { de: 'Über mich', en: 'About' },
|
||||
contact: { de: 'Kontakt', en: 'Contact' },
|
||||
imprint: { de: 'Impressum', en: 'Legal notice' },
|
||||
privacy: { de: 'Datenschutz', en: 'Privacy' },
|
||||
notFound: { de: 'Seite nicht gefunden', en: 'Page not found' },
|
||||
};
|
||||
|
||||
function scaffoldPage(routeId: RouteId, locale: AppLocale): PageCopy {
|
||||
const title = PAGE_TITLES[routeId][locale];
|
||||
const description =
|
||||
locale === 'de' ? 'Diese Seite wird derzeit aufgebaut.' : 'This page is being built.';
|
||||
|
||||
return {
|
||||
routeId,
|
||||
title,
|
||||
description,
|
||||
hero: {
|
||||
headline: title,
|
||||
body: [description],
|
||||
},
|
||||
sections: [],
|
||||
ctas:
|
||||
routeId === 'notFound'
|
||||
? [
|
||||
{
|
||||
label: locale === 'de' ? 'Zur Startseite' : 'Back to home',
|
||||
routeId: 'home',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function pagesFor(locale: AppLocale): SiteContent {
|
||||
const pages = Object.fromEntries(
|
||||
ROUTE_IDS.map((routeId) => [routeId, scaffoldPage(routeId, locale)]),
|
||||
) as Record<RouteId, PageCopy>;
|
||||
|
||||
return { pages };
|
||||
}
|
||||
|
||||
export const PLACEHOLDER_CONTENT: Record<AppLocale, SiteContent> = {
|
||||
de: pagesFor('de'),
|
||||
en: pagesFor('en'),
|
||||
};
|
||||
@@ -10,9 +10,6 @@ export interface ShellCopy {
|
||||
readonly otherLocaleName: string;
|
||||
readonly cvLabel: string;
|
||||
readonly contactCta: string;
|
||||
readonly scaffoldingNote: string;
|
||||
readonly legalReviewNotice: string;
|
||||
readonly notFoundHomeLabel: string;
|
||||
}
|
||||
|
||||
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
@@ -26,10 +23,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
otherLocaleName: 'English',
|
||||
cvLabel: 'Lebenslauf als PDF',
|
||||
contactCta: 'Kontakt',
|
||||
scaffoldingNote: 'Hinweis: Der endgültige Inhalt folgt. Diese Seite ist ein Gerüst.',
|
||||
legalReviewNotice:
|
||||
'Platzhalter: Dieser Rechtstext ist ungeprüft und muss vor der Veröffentlichung vom Seitenbetreiber geprüft werden.',
|
||||
notFoundHomeLabel: 'Zur Startseite',
|
||||
},
|
||||
en: {
|
||||
skipLink: 'Skip to content',
|
||||
@@ -41,9 +34,5 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
otherLocaleName: 'Deutsch',
|
||||
cvLabel: 'Curriculum vitae as PDF',
|
||||
contactCta: 'Contact',
|
||||
scaffoldingNote: 'Note: Final content follows. This page is scaffolding.',
|
||||
legalReviewNotice:
|
||||
'Placeholder: This legal text is unreviewed and must be checked by the site owner before publication.',
|
||||
notFoundHomeLabel: 'Back to home',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { COMMAND_IDS } from '../../shared/command-palette/commands';
|
||||
import { SIGNATURE_COPY } from './signature-copy';
|
||||
|
||||
function leafPaths(value: unknown, prefix = ''): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.keys(value).flatMap((key) => {
|
||||
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||
return leafPaths((value as Record<string, unknown>)[key], next);
|
||||
});
|
||||
}
|
||||
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.values(value).flatMap((entry) => collectStrings(entry));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
describe('SIGNATURE_COPY', () => {
|
||||
it('exposes the same key structure in both locales', () => {
|
||||
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
|
||||
|
||||
for (const keys of rest) {
|
||||
expect(keys).toEqual(first);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every string non-empty', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
for (const id of COMMAND_IDS) {
|
||||
expect(descriptions[id].trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type CommandId } from '../../shared/command-palette/commands';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly palette: {
|
||||
readonly triggerLabel: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly dialogTitle: string;
|
||||
readonly dialogDescription: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly closeLabel: string;
|
||||
readonly suggestionsLabel: string;
|
||||
readonly outputLabel: string;
|
||||
readonly emptySuggestions: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly helpIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly ignite: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
readonly diagramDescription: string;
|
||||
readonly listHeading: string;
|
||||
readonly legendHeading: string;
|
||||
readonly relationshipLabel: string;
|
||||
readonly legend: {
|
||||
readonly ai: string;
|
||||
readonly cluster: string;
|
||||
readonly hardware: string;
|
||||
readonly software: string;
|
||||
readonly project: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
palette: {
|
||||
triggerLabel: 'Befehle öffnen',
|
||||
shortcutHint: 'Strg+K',
|
||||
dialogTitle: 'Befehle',
|
||||
dialogDescription:
|
||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
closeLabel: 'Schließen',
|
||||
suggestionsLabel: 'Vorschläge',
|
||||
outputLabel: 'Ausgabe',
|
||||
emptySuggestions: 'Keine passenden Befehle.',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
commandDescriptions: {
|
||||
help: 'Listet die verfügbaren Befehle.',
|
||||
projects: 'Öffnet die Projektübersicht.',
|
||||
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
||||
cv: 'Öffnet den Lebenslauf als PDF.',
|
||||
contact: 'Öffnet die Kontaktseite.',
|
||||
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
||||
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||
clear: 'Leert die Ausgabe.',
|
||||
close: 'Schließt die Befehlsübersicht.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||
ignite: 'Zündung frei. Die Systeme laufen warm.',
|
||||
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systemkarte',
|
||||
intro:
|
||||
'Wie die technischen Schichten zusammenhängen — von Hardware bis zu den Projektfeldern.',
|
||||
diagramDescription: 'Diagramm der technischen Schichten und ihrer Verbindungen.',
|
||||
listHeading: 'Knoten als Liste',
|
||||
legendHeading: 'Legende',
|
||||
relationshipLabel: 'Verbunden mit {targets}.',
|
||||
legend: {
|
||||
ai: 'KI-Integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware und Netz',
|
||||
software: 'Software',
|
||||
project: 'Projektfeld',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
palette: {
|
||||
triggerLabel: 'Open commands',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
dialogTitle: 'Commands',
|
||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
closeLabel: 'Close',
|
||||
suggestionsLabel: 'Suggestions',
|
||||
outputLabel: 'Output',
|
||||
emptySuggestions: 'No matching commands.',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
helpIntro: 'Available commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
commandDescriptions: {
|
||||
help: 'Lists the available commands.',
|
||||
projects: 'Opens the projects overview.',
|
||||
servicesAi: 'Opens the AI integration page.',
|
||||
cv: 'Opens the CV as a PDF.',
|
||||
contact: 'Opens the contact page.',
|
||||
brew: 'A short playful acknowledgement.',
|
||||
ignite: 'A short playful acknowledgement.',
|
||||
rev: 'A short playful acknowledgement.',
|
||||
clear: 'Clears the output.',
|
||||
close: 'Closes the command palette.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||
ignite: 'Ignition on. Systems are warming up.',
|
||||
rev: 'Revs climbing, the content stays calm.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systems map',
|
||||
intro: 'How the technical layers connect — from hardware through to the project fields.',
|
||||
diagramDescription: 'Diagram of the technical layers and their connections.',
|
||||
listHeading: 'Nodes as a list',
|
||||
legendHeading: 'Legend',
|
||||
relationshipLabel: 'Connected to {targets}.',
|
||||
legend: {
|
||||
ai: 'AI integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware and network',
|
||||
software: 'Software',
|
||||
project: 'Project field',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
export const SITE_CONFIG = {
|
||||
export const SITE_CONFIG: {
|
||||
readonly personName: string;
|
||||
readonly contactEmail: string;
|
||||
readonly cvAssetPath: string;
|
||||
readonly cvDownloadFileName: string;
|
||||
readonly calendarUrl: string | null;
|
||||
} = {
|
||||
personName: 'Antonio Ledebuhr',
|
||||
contactEmail: 'info@antoniolede.de',
|
||||
cvAssetPath: '/cv/CV.pdf',
|
||||
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
|
||||
calendarUrl: null,
|
||||
} as const;
|
||||
};
|
||||
|
||||
9
src/app/core/content/site-content.ts
Normal file
9
src/app/core/content/site-content.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type SiteContent } from './content.contracts';
|
||||
import { SITE_CONTENT_DE } from './de/site';
|
||||
import { SITE_CONTENT_EN } from './en/site';
|
||||
|
||||
export const SITE_CONTENT_DATA: Record<AppLocale, SiteContent> = {
|
||||
de: SITE_CONTENT_DE,
|
||||
en: SITE_CONTENT_EN,
|
||||
};
|
||||
@@ -2,15 +2,15 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../../app.routes';
|
||||
import { PLACEHOLDER_CONTENT } from '../content/placeholder-content';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, ContentSection],
|
||||
templateUrl: './about.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class AboutPage {
|
||||
protected readonly page = inject(ContentService).page('about');
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<app-contact-briefing [copy]="copy" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { ContactBriefing } from '../../shared/contact-briefing/contact-briefing';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, ContentSection, ContactBriefing],
|
||||
templateUrl: './contact.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ContactPage {
|
||||
protected readonly page = inject(ContentService).page('contact');
|
||||
protected readonly page = inject(ContentService).contact();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,35 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
@if (section.id === 'profile') {
|
||||
<ul class="stack">
|
||||
@for (line of copy.profile; track $index) {
|
||||
<li>{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
||||
}
|
||||
@if (section.id === 'featured-cases') {
|
||||
<div class="stack">
|
||||
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
||||
<app-case-card [caseStudy]="caseStudy" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@for (audience of copy.audiences; track audience.id) {
|
||||
<section class="stack" [id]="audience.id" [attr.aria-labelledby]="'audience-' + audience.id">
|
||||
<h2 [id]="'audience-' + audience.id">{{ audience.headline }}</h2>
|
||||
<p>{{ audience.body }}</p>
|
||||
<ul>
|
||||
@for (bullet of audience.bullets; track $index) {
|
||||
<li>{{ bullet }}</li>
|
||||
}
|
||||
</ul>
|
||||
<app-cta-row [ctas]="audience.ctas" />
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
13
src/app/features/home/home.scss
Normal file
13
src/app/features/home/home.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@use '../../shared/page-shell';
|
||||
|
||||
.page h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page p,
|
||||
.page li {
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { CaseCard } from '../../shared/case-card/case-card';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||
import { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
export class HomePage {
|
||||
protected readonly page = inject(ContentService).page('home');
|
||||
private readonly content = inject(ContentService);
|
||||
|
||||
protected readonly page = this.content.home();
|
||||
protected readonly featuredCases = computed(() => {
|
||||
const ids = this.page().featuredCaseIds;
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
<app-legal-review-notice [notice]="copy.reviewNotice" [todos]="copy.reviewTodos" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
@for (legal of copy.legalSections; track legal.id) {
|
||||
<section class="stack" [attr.aria-labelledby]="'legal-' + legal.id">
|
||||
<h2 [id]="'legal-' + legal.id">{{ legal.title }}</h2>
|
||||
@for (paragraph of legal.body; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { LegalReviewNotice } from '../../shared/legal-review-notice/legal-review-notice';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-imprint-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, LegalReviewNotice, ContentSection],
|
||||
templateUrl: './imprint.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ImprintPage {
|
||||
protected readonly page = inject(ContentService).page('imprint');
|
||||
protected readonly page = inject(ContentService).legal('imprint');
|
||||
}
|
||||
|
||||
39
src/app/features/legal-review.spec.ts
Normal file
39
src/app/features/legal-review.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../app.routes';
|
||||
import { SITE_CONTENT } from '../core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../core/content/site-content';
|
||||
|
||||
describe('legal review notice', () => {
|
||||
it('renders the review notice on legal pages and keeps it off the home page', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const [path, locale, legalId] of [
|
||||
['/impressum', 'de', 'imprint'],
|
||||
['/en/legal-notice', 'en', 'imprint'],
|
||||
['/datenschutz', 'de', 'privacy'],
|
||||
['/en/privacy', 'en', 'privacy'],
|
||||
] as const) {
|
||||
await harness.navigateByUrl(path);
|
||||
const text = harness.routeNativeElement?.textContent ?? '';
|
||||
const legal = SITE_CONTENT_DATA[locale].legal[legalId];
|
||||
expect(text).toContain(legal.reviewNotice);
|
||||
expect(legal.reviewTodos.some((todo) => text.includes(todo))).toBe(true);
|
||||
}
|
||||
|
||||
await harness.navigateByUrl('/');
|
||||
expect(harness.routeNativeElement?.textContent).not.toContain(
|
||||
SITE_CONTENT_DATA.de.legal.imprint.reviewNotice,
|
||||
);
|
||||
|
||||
await harness.navigateByUrl('/en');
|
||||
expect(harness.routeNativeElement?.textContent).not.toContain(
|
||||
SITE_CONTENT_DATA.en.legal.imprint.reviewNotice,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero],
|
||||
templateUrl: './not-found.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class NotFoundPage {
|
||||
protected readonly page = inject(ContentService).page('notFound');
|
||||
|
||||
118
src/app/features/page-rendering.spec.ts
Normal file
118
src/app/features/page-rendering.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { RouterTestingHarness } from '@angular/router/testing';
|
||||
import { routes } from '../app.routes';
|
||||
import { SITE_CONTENT } from '../core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../core/content/site-content';
|
||||
import { SITE_CONFIG } from '../core/content/site-config';
|
||||
import { CASE_STUDY_IDS } from '../core/content/content.contracts';
|
||||
import { APP_LOCALES } from '../core/i18n/locale';
|
||||
import { routePath } from '../core/routing/route-paths';
|
||||
|
||||
function headingLevel(element: Element): number {
|
||||
return Number(element.tagName.slice(1));
|
||||
}
|
||||
|
||||
function accessibleName(root: HTMLElement, element: Element): string {
|
||||
const labelledBy = element.getAttribute('aria-labelledby');
|
||||
if (labelledBy) {
|
||||
return labelledBy
|
||||
.split(/\s+/)
|
||||
.map((id) => root.querySelector(`[id="${id}"]`)?.textContent ?? '')
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return (element.getAttribute('aria-label') ?? element.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
function assertPageSemantics(root: HTMLElement): void {
|
||||
const headings = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')];
|
||||
expect(headings.filter((heading) => heading.tagName === 'H1')).toHaveLength(1);
|
||||
|
||||
let previous = 1;
|
||||
for (const heading of headings) {
|
||||
const level = headingLevel(heading);
|
||||
expect(level).toBeLessThanOrEqual(previous + 1);
|
||||
previous = level;
|
||||
}
|
||||
|
||||
for (const link of root.querySelectorAll('a')) {
|
||||
expect(accessibleName(root, link).length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
for (const region of root.querySelectorAll('section, article')) {
|
||||
expect(accessibleName(root, region).length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
describe('page rendering, semantics and accessibility', () => {
|
||||
it('renders home, a service detail page, projects and 404 in both locales', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/leistungen/software',
|
||||
'/en/services/software',
|
||||
'/projekte',
|
||||
'/en/projects',
|
||||
'/missing-route',
|
||||
'/en/missing-route',
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes case anchors, audience entries and the CV download', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/projekte', '/en/projects']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
for (const caseId of CASE_STUDY_IDS) {
|
||||
expect(root.querySelector(`#${caseId}`)).toBeTruthy();
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of ['/', '/en']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root.querySelector('#recruiters')).toBeTruthy();
|
||||
expect(root.querySelector('#companies')).toBeTruthy();
|
||||
expect(root.querySelector(`a[href="${SITE_CONFIG.cvAssetPath}"]`)).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends the software service case CTA to the innofocus project anchor', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
await harness.navigateByUrl(routePath('servicesSoftware', locale));
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const expected = `${routePath('projects', locale)}#innofocus`;
|
||||
const hrefs = [...root.querySelectorAll('a')].map(
|
||||
(anchor) => anchor.getAttribute('href') ?? '',
|
||||
);
|
||||
expect(hrefs.some((href) => href.endsWith(expected))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
<app-legal-review-notice [notice]="copy.reviewNotice" [todos]="copy.reviewTodos" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
@for (legal of copy.legalSections; track legal.id) {
|
||||
<section class="stack" [attr.aria-labelledby]="'legal-' + legal.id">
|
||||
<h2 [id]="'legal-' + legal.id">{{ legal.title }}</h2>
|
||||
@for (paragraph of legal.body; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { LegalReviewNotice } from '../../shared/legal-review-notice/legal-review-notice';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, LegalReviewNotice, ContentSection],
|
||||
templateUrl: './privacy.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class PrivacyPage {
|
||||
protected readonly page = inject(ContentService).page('privacy');
|
||||
protected readonly page = inject(ContentService).legal('privacy');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
@for (caseStudy of cases(); track caseStudy.id) {
|
||||
<app-case-study
|
||||
[caseStudy]="caseStudy"
|
||||
[situationLabel]="copy.caseLabels.situation"
|
||||
[approachLabel]="copy.caseLabels.approach"
|
||||
[outcomeLabel]="copy.caseLabels.outcome"
|
||||
[stackLabel]="copy.caseLabels.stack"
|
||||
[tagsLabel]="copy.caseLabels.tags"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { CaseStudy } from '../../shared/case-study/case-study';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, ContentSection, CaseStudy],
|
||||
templateUrl: './projects.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ProjectsPage {
|
||||
protected readonly page = inject(ContentService).page('projects');
|
||||
private readonly content = inject(ContentService);
|
||||
|
||||
protected readonly page = this.content.projects();
|
||||
protected readonly cases = this.content.cases();
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
<app-service-page-view routeId="servicesAi" />
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ServicePageView } from '../service-page-view/service-page-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-ai-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [ServicePageView],
|
||||
templateUrl: './ai-integration.html',
|
||||
})
|
||||
export class ServicesAiPage {
|
||||
protected readonly page = inject(ContentService).page('servicesAi');
|
||||
}
|
||||
export class ServicesAiPage {}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
<app-service-page-view routeId="servicesClusters" />
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ServicePageView } from '../service-page-view/service-page-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-clusters-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [ServicePageView],
|
||||
templateUrl: './clusters.html',
|
||||
})
|
||||
export class ServicesClustersPage {
|
||||
protected readonly page = inject(ContentService).page('servicesClusters');
|
||||
}
|
||||
export class ServicesClustersPage {}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
<app-service-page-view routeId="servicesHardwareNetwork" />
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ServicePageView } from '../service-page-view/service-page-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-hardware-network-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [ServicePageView],
|
||||
templateUrl: './hardware-network.html',
|
||||
})
|
||||
export class ServicesHardwareNetworkPage {
|
||||
protected readonly page = inject(ContentService).page('servicesHardwareNetwork');
|
||||
}
|
||||
export class ServicesHardwareNetworkPage {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
@if (page(); as copy) {
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
@if (section.id === 'sequence') {
|
||||
<app-process-steps [steps]="steps()" [labelledBy]="sequenceHeadingId()" />
|
||||
}
|
||||
@if (section.id === 'offer-boundary' && copy.offerings.length > 0) {
|
||||
<ul class="stack offerings">
|
||||
@for (offering of copy.offerings; track offering.id) {
|
||||
<li class="glass-surface">
|
||||
<h3>{{ offering.title }}</h3>
|
||||
<p>{{ offering.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
<section class="stack" [attr.aria-labelledby]="'reference-' + copy.routeId">
|
||||
<h2 [id]="'reference-' + copy.routeId">{{ copy.sequence.referenceHeadline }}</h2>
|
||||
<p>{{ copy.sequence.referenceNote }}</p>
|
||||
<app-case-card [caseStudy]="referenceCase()" />
|
||||
</section>
|
||||
<app-content-section [section]="copy.sequence.inquiry" />
|
||||
<app-cta-row [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../../../shared/page-shell';
|
||||
|
||||
.offerings {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.offerings li {
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.offerings h3 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.offerings p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import {
|
||||
type ProcessStepCopy,
|
||||
type ServicePageId,
|
||||
type ServiceSequenceCopy,
|
||||
} from '../../../core/content/content.contracts';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { CaseCard } from '../../../shared/case-card/case-card';
|
||||
import { ContentSection } from '../../../shared/content-section/content-section';
|
||||
import { CtaRow } from '../../../shared/cta-row/cta-row';
|
||||
import { PageHero } from '../../../shared/page-hero/page-hero';
|
||||
import { ProcessSteps } from '../../../shared/process-steps/process-steps';
|
||||
|
||||
function sequenceSteps(sequence: ServiceSequenceCopy): readonly ProcessStepCopy[] {
|
||||
return [sequence.situation, sequence.diagnosis, sequence.implementation, sequence.operation].map(
|
||||
(section) => ({
|
||||
id: section.id,
|
||||
title: section.headline,
|
||||
body: (section.body ?? []).join(' '),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-service-page-view',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow],
|
||||
templateUrl: './service-page-view.html',
|
||||
styleUrl: './service-page-view.scss',
|
||||
})
|
||||
export class ServicePageView {
|
||||
readonly routeId = input.required<ServicePageId>();
|
||||
|
||||
private readonly content = inject(ContentService);
|
||||
|
||||
protected readonly page = computed(() => this.content.service(this.routeId())());
|
||||
protected readonly steps = computed(() => sequenceSteps(this.page().sequence));
|
||||
protected readonly referenceCase = computed(() =>
|
||||
this.content.caseStudy(this.page().sequence.referenceCaseId)(),
|
||||
);
|
||||
protected readonly sequenceHeadingId = computed(() => {
|
||||
const section = this.page().sections.find((item) => item.id === 'sequence');
|
||||
return section ? `section-${section.id}` : null;
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [PageHero, ContentSection],
|
||||
templateUrl: './services.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class ServicesPage {
|
||||
protected readonly page = inject(ContentService).page('services');
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
<app-service-page-view routeId="servicesSoftware" />
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../../core/content/content.service';
|
||||
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ServicePageView } from '../service-page-view/service-page-view';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-software-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PagePlaceholder],
|
||||
imports: [ServicePageView],
|
||||
templateUrl: './software.html',
|
||||
})
|
||||
export class ServicesSoftwarePage {
|
||||
protected readonly page = inject(ContentService).page('servicesSoftware');
|
||||
}
|
||||
export class ServicesSoftwarePage {}
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
<app-skills />
|
||||
@if (page(); as copy) {
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<app-skills-grid [categoryTitles]="categoryTitles()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { Skills } from '../../components/pages/skills/skills';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { SkillsGrid } from '../../components/pages/skills/skills-grid/skills-grid';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stack-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [Skills],
|
||||
imports: [PageHero, ContentSection, SkillsGrid],
|
||||
templateUrl: './stack.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class StackPage {}
|
||||
export class StackPage {
|
||||
protected readonly page = inject(ContentService).stack();
|
||||
protected readonly categoryTitles = computed(() => {
|
||||
const titles: Record<string, string> = {};
|
||||
for (const group of this.page().groups) {
|
||||
titles[group.id] = group.title;
|
||||
}
|
||||
return titles;
|
||||
});
|
||||
}
|
||||
|
||||
14
src/app/shared/case-card/case-card.html
Normal file
14
src/app/shared/case-card/case-card.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<article
|
||||
class="case-card glass-surface stack"
|
||||
[attr.aria-labelledby]="'case-card-' + caseStudy().id"
|
||||
>
|
||||
<h3 [id]="'case-card-' + caseStudy().id">{{ caseStudy().headline }}</h3>
|
||||
<p class="meta">{{ caseStudy().client }} · {{ caseStudy().role }} · {{ caseStudy().period }}</p>
|
||||
<p>{{ caseStudy().summary }}</p>
|
||||
<ul class="cluster tags">
|
||||
@for (tag of caseStudy().tags; track $index) {
|
||||
<li>{{ tag }}</li>
|
||||
}
|
||||
</ul>
|
||||
<a [routerLink]="projectsLink()" [fragment]="caseStudy().id">{{ caseStudy().linkLabel }}</a>
|
||||
</article>
|
||||
36
src/app/shared/case-card/case-card.scss
Normal file
36
src/app/shared/case-card/case-card.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.case-card {
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-md);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta,
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tags {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent-cool);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
19
src/app/shared/case-card/case-card.ts
Normal file
19
src/app/shared/case-card/case-card.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { type CaseStudyCopy } from '../../core/content/content.contracts';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-case-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
templateUrl: './case-card.html',
|
||||
styleUrl: './case-card.scss',
|
||||
})
|
||||
export class CaseCard {
|
||||
readonly caseStudy = input.required<CaseStudyCopy>();
|
||||
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected readonly projectsLink = computed(() => this.navigation.link('projects'));
|
||||
}
|
||||
59
src/app/shared/case-study/case-study.html
Normal file
59
src/app/shared/case-study/case-study.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<article class="case-study stack" [id]="caseStudy().id" [attr.aria-labelledby]="headingId()">
|
||||
<header class="stack">
|
||||
<h2 [id]="headingId()">{{ caseStudy().headline }}</h2>
|
||||
<p class="meta">
|
||||
{{ caseStudy().client }} · {{ caseStudy().role }} · {{ caseStudy().period }} ·
|
||||
{{ caseStudy().domain }}
|
||||
</p>
|
||||
<p>{{ caseStudy().summary }}</p>
|
||||
<p>{{ caseStudy().responsibility }}</p>
|
||||
@if (caseStudy().transparencyNote; as note) {
|
||||
<p class="transparency" role="note">{{ note }}</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-situation'">
|
||||
<h3 [id]="caseStudy().id + '-situation'">{{ situationLabel() }}</h3>
|
||||
<p>{{ caseStudy().situation }}</p>
|
||||
</section>
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-approach'">
|
||||
<h3 [id]="caseStudy().id + '-approach'">{{ approachLabel() }}</h3>
|
||||
<ol>
|
||||
@for (item of caseStudy().approach; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-outcome'">
|
||||
<h3 [id]="caseStudy().id + '-outcome'">{{ outcomeLabel() }}</h3>
|
||||
<ul>
|
||||
@for (item of caseStudy().outcome; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@if (caseStudy().metrics.length > 0) {
|
||||
<app-metric-list [metrics]="caseStudy().metrics" [labelledBy]="metricsLabelId()" />
|
||||
}
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-stack'">
|
||||
<h3 [id]="caseStudy().id + '-stack'">{{ stackLabel() }}</h3>
|
||||
<ul class="cluster tags">
|
||||
@for (item of caseStudy().stack; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-tags'">
|
||||
<h3 [id]="caseStudy().id + '-tags'">{{ tagsLabel() }}</h3>
|
||||
<ul class="cluster tags">
|
||||
@for (tag of caseStudy().tags; track $index) {
|
||||
<li>{{ tag }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
50
src/app/shared/case-study/case-study.scss
Normal file
50
src/app/shared/case-study/case-study.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.case-study {
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta,
|
||||
p,
|
||||
li {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.meta,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.transparency {
|
||||
padding: var(--space-3);
|
||||
border-left: var(--focus-ring-width) solid var(--color-accent);
|
||||
background: var(--color-surface-overlay);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.tags {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
22
src/app/shared/case-study/case-study.ts
Normal file
22
src/app/shared/case-study/case-study.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { type CaseStudyCopy } from '../../core/content/content.contracts';
|
||||
import { MetricList } from '../metric-list/metric-list';
|
||||
|
||||
@Component({
|
||||
selector: 'app-case-study',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MetricList],
|
||||
templateUrl: './case-study.html',
|
||||
styleUrl: './case-study.scss',
|
||||
})
|
||||
export class CaseStudy {
|
||||
readonly caseStudy = input.required<CaseStudyCopy>();
|
||||
readonly situationLabel = input.required<string>();
|
||||
readonly approachLabel = input.required<string>();
|
||||
readonly outcomeLabel = input.required<string>();
|
||||
readonly stackLabel = input.required<string>();
|
||||
readonly tagsLabel = input.required<string>();
|
||||
|
||||
protected readonly headingId = computed(() => `case-heading-${this.caseStudy().id}`);
|
||||
protected readonly metricsLabelId = computed(() => `case-metrics-${this.caseStudy().id}`);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="dialogId"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ copy().shortcutHint }}</kbd>
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<div class="command-palette-overlay">
|
||||
<div class="command-palette-scrim" aria-hidden="true"></div>
|
||||
<div
|
||||
#dialog
|
||||
class="command-palette-dialog glass-surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[id]="dialogId"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
[attr.aria-describedby]="descriptionId"
|
||||
(keydown)="onDialogKeydown($event, dialog)"
|
||||
>
|
||||
<div class="command-palette-header">
|
||||
<div>
|
||||
<h2 [id]="titleId" class="command-palette-title">{{ copy().dialogTitle }}</h2>
|
||||
<p [id]="descriptionId" class="command-palette-description">
|
||||
{{ copy().dialogDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="command-palette-close" (click)="closePalette()">
|
||||
{{ copy().closeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="command-palette-form" (submit)="onSubmit($event)">
|
||||
<label [attr.for]="inputId">{{ copy().inputLabel }}</label>
|
||||
<input
|
||||
#commandInput
|
||||
[id]="inputId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
[attr.placeholder]="copy().inputPlaceholder"
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
/>
|
||||
|
||||
<p class="command-palette-kicker" [id]="suggestionsId">{{ copy().suggestionsLabel }}</p>
|
||||
@if (suggestions().length === 0) {
|
||||
<p class="command-palette-empty">{{ copy().emptySuggestions }}</p>
|
||||
} @else {
|
||||
<ul class="command-palette-suggestions" [attr.aria-labelledby]="suggestionsId">
|
||||
@for (command of suggestions(); track command.id) {
|
||||
<li>
|
||||
<button type="button" (click)="runDefinition(command)">
|
||||
<span>{{ command.input }}</span>
|
||||
<span>{{ copy().commandDescriptions[command.id] }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</form>
|
||||
|
||||
<div
|
||||
class="command-palette-output"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
[attr.aria-label]="copy().outputLabel"
|
||||
>
|
||||
<ul>
|
||||
@for (line of output(); track line.id) {
|
||||
<li>{{ line.text }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.command-palette-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.command-palette-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
width: min(100%, 40rem);
|
||||
max-height: min(36rem, 90vh);
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-description,
|
||||
.command-palette-kicker,
|
||||
.command-palette-empty,
|
||||
.command-palette-output ul,
|
||||
.command-palette-suggestions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.command-palette-description,
|
||||
.command-palette-empty,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-close {
|
||||
min-height: 2.75rem;
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-form input {
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-kicker {
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.command-palette-suggestions,
|
||||
.command-palette-output ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-palette-suggestions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:first-child {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:last-child,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-output {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover,
|
||||
.command-palette-close:hover,
|
||||
.command-palette-suggestions button:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.command-palette-overlay,
|
||||
.command-palette-dialog {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { CommandPalette } from './command-palette';
|
||||
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<CommandPalette>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommandPalette],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(CommandPalette);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||
}
|
||||
|
||||
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(
|
||||
fixture: ComponentFixture<CommandPalette>,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.nativeElement
|
||||
.querySelector('form')
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
function outputText(fixture: ComponentFixture<CommandPalette>): string {
|
||||
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
||||
}
|
||||
|
||||
function focusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => element.tabIndex >= 0);
|
||||
}
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'renders the trigger and not the dialog on the server (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(trigger(fixture)).toBeTruthy();
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await flush(fixture);
|
||||
|
||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||
const openDialog = dialog(fixture);
|
||||
if (openDialog) {
|
||||
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifier,
|
||||
});
|
||||
const prevent = vi.spyOn(event, 'preventDefault');
|
||||
document.dispatchEvent(event);
|
||||
await flush(fixture);
|
||||
|
||||
expect(prevent).toHaveBeenCalled();
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
|
||||
closeButton.click();
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
const items = focusable(openDialog as HTMLElement);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
|
||||
last.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
first.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const copy = SIGNATURE_COPY[locale].palette;
|
||||
|
||||
await submitQuery(fixture, 'help');
|
||||
const helpText = outputText(fixture);
|
||||
expect(helpText).toContain(copy.helpIntro);
|
||||
for (const command of COMMANDS) {
|
||||
expect(helpText).toContain(command.input);
|
||||
expect(helpText).toContain(copy.commandDescriptions[command.id]);
|
||||
}
|
||||
expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true);
|
||||
|
||||
await submitQuery(fixture, 'brew');
|
||||
expect(outputText(fixture)).toContain(copy.responses.brew);
|
||||
await submitQuery(fixture, 'ignite');
|
||||
expect(outputText(fixture)).toContain(copy.responses.ignite);
|
||||
await submitQuery(fixture, 'rev');
|
||||
expect(outputText(fixture)).toContain(copy.responses.rev);
|
||||
|
||||
await submitQuery(fixture, 'clear');
|
||||
expect(outputText(fixture).trim()).toBe(copy.clearedMessage);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'navigates projects, services ai and contact through NavigationService (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'projects');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'services ai');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi'));
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'contact');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('contact'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'cv');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'reports unknown input without navigating or opening a window (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
const raw = 'rm -rf /';
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, raw);
|
||||
|
||||
expect(outputText(fixture)).toContain(
|
||||
SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw),
|
||||
);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('removes the document keydown listener on destroy', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
const fixture = await createFixture();
|
||||
await flush(fixture);
|
||||
|
||||
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
||||
expect(added).toBeTruthy();
|
||||
|
||||
const remove = vi.spyOn(document, 'removeEventListener');
|
||||
fixture.destroy();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||
});
|
||||
|
||||
it('registers no document listener on the server', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandDefinition,
|
||||
} from './commands';
|
||||
|
||||
let commandPaletteInstanceId = 0;
|
||||
let commandPaletteOutputId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-palette',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './command-palette.html',
|
||||
styleUrl: './command-palette.scss',
|
||||
})
|
||||
export class CommandPalette {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly instanceId = commandPaletteInstanceId++;
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||
|
||||
protected readonly dialogId = `command-palette-dialog-${this.instanceId}`;
|
||||
protected readonly titleId = `command-palette-title-${this.instanceId}`;
|
||||
protected readonly descriptionId = `command-palette-description-${this.instanceId}`;
|
||||
protected readonly inputId = `command-palette-input-${this.instanceId}`;
|
||||
protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`;
|
||||
|
||||
protected readonly open = signal(false);
|
||||
protected readonly query = signal('');
|
||||
protected readonly output = signal<readonly { id: number; text: string }[]>([]);
|
||||
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||
protected readonly suggestions = computed(() =>
|
||||
suggestCommands(this.query(), this.localeService.locale()),
|
||||
);
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
});
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
this.openPalette();
|
||||
}
|
||||
|
||||
protected onQueryInput(event: Event): void {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.query.set(target.value);
|
||||
}
|
||||
|
||||
protected onSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.runRaw(this.query());
|
||||
}
|
||||
|
||||
protected onDialogKeydown(event: KeyboardEvent, dialog: HTMLElement): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = this.focusableElements(dialog);
|
||||
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = this.document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected runDefinition(definition: CommandDefinition): void {
|
||||
const copy = this.copy();
|
||||
|
||||
switch (definition.action.kind) {
|
||||
case 'navigate': {
|
||||
void this.router.navigate(this.navigation.link(definition.action.routeId));
|
||||
this.append(copy.navigating.replace('{target}', definition.input));
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
case 'openCv': {
|
||||
if (this.isBrowser) {
|
||||
this.document.defaultView?.open(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
this.append(copy.cvOpened);
|
||||
return;
|
||||
}
|
||||
case 'message': {
|
||||
this.appendMessage(definition.action.messageKey);
|
||||
return;
|
||||
}
|
||||
case 'clear': {
|
||||
this.output.set([]);
|
||||
this.append(copy.clearedMessage);
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runRaw(raw: string): void {
|
||||
const match = parseCommand(raw);
|
||||
|
||||
if (match.kind === 'empty') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.kind === 'unknown') {
|
||||
this.append(this.copy().unknownCommand.replace('{command}', match.input));
|
||||
return;
|
||||
}
|
||||
|
||||
this.runDefinition(match.definition);
|
||||
}
|
||||
|
||||
private appendMessage(key: 'help' | 'brew' | 'ignite' | 'rev'): void {
|
||||
const copy = this.copy();
|
||||
|
||||
if (key === 'help') {
|
||||
const lines = [
|
||||
copy.helpIntro,
|
||||
...COMMAND_IDS.map((id) => {
|
||||
const command = COMMANDS.find((entry) => entry.id === id);
|
||||
const input = command?.input ?? id;
|
||||
return `${input} — ${copy.commandDescriptions[id]}`;
|
||||
}),
|
||||
];
|
||||
this.append(lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.append(copy.responses[key]);
|
||||
}
|
||||
|
||||
private append(text: string): void {
|
||||
this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]);
|
||||
}
|
||||
|
||||
private openPalette(): void {
|
||||
if (this.open()) {
|
||||
this.focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.open.set(true);
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
protected closePalette(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private focusInput(): void {
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private focusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { APP_LOCALES } from '../../core/i18n/locale';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
normalizeCommandInput,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandId,
|
||||
} from './commands';
|
||||
|
||||
describe('command parsing', () => {
|
||||
it('collapses whitespace and lowercases input', () => {
|
||||
expect(normalizeCommandInput(' Services AI ')).toBe('services ai');
|
||||
expect(normalizeCommandInput('HELP')).toBe('help');
|
||||
});
|
||||
|
||||
it('resolves every CommandId from its canonical input and aliases in both locales', () => {
|
||||
for (const command of COMMANDS) {
|
||||
const canonical = parseCommand(command.input);
|
||||
expect(canonical).toEqual({ kind: 'command', definition: command });
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
expect(parseCommand(alias)).toEqual({ kind: 'command', definition: command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(COMMANDS.map((command) => command.id)).toEqual([...COMMAND_IDS]);
|
||||
});
|
||||
|
||||
it('treats hostile and prototype inputs as unknown without side effects', () => {
|
||||
const prototypeNames = Object.getOwnPropertyNames(Object.prototype);
|
||||
const hostile = [
|
||||
'rm -rf /',
|
||||
'eval(1+1)',
|
||||
'new Function()',
|
||||
'<script>alert(1)</script>',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'toString',
|
||||
'hasOwnProperty',
|
||||
];
|
||||
|
||||
for (const input of hostile) {
|
||||
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
|
||||
}
|
||||
|
||||
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
||||
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
|
||||
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
|
||||
expect(Object.prototype).not.toHaveProperty('polluted');
|
||||
});
|
||||
|
||||
it('returns an equal result for the same input across repeated calls', () => {
|
||||
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(parseCommand(sample)).toEqual(parseCommand(sample));
|
||||
}
|
||||
});
|
||||
|
||||
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
|
||||
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
|
||||
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
|
||||
const expectedIds: CommandId[] = [...COMMAND_IDS];
|
||||
|
||||
expect(emptyDe).toEqual(expectedIds);
|
||||
expect(emptyEn).toEqual(expectedIds);
|
||||
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
|
||||
'cv',
|
||||
'contact',
|
||||
'clear',
|
||||
'close',
|
||||
]);
|
||||
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
|
||||
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export type CommandId =
|
||||
| 'help'
|
||||
| 'projects'
|
||||
| 'servicesAi'
|
||||
| 'cv'
|
||||
| 'contact'
|
||||
| 'brew'
|
||||
| 'ignite'
|
||||
| 'rev'
|
||||
| 'clear'
|
||||
| 'close';
|
||||
|
||||
export const COMMAND_IDS: readonly CommandId[] = [
|
||||
'help',
|
||||
'projects',
|
||||
'servicesAi',
|
||||
'cv',
|
||||
'contact',
|
||||
'brew',
|
||||
'ignite',
|
||||
'rev',
|
||||
'clear',
|
||||
'close',
|
||||
];
|
||||
|
||||
export type CommandAction =
|
||||
| { readonly kind: 'navigate'; readonly routeId: RouteId }
|
||||
| { readonly kind: 'openCv' }
|
||||
| { readonly kind: 'message'; readonly messageKey: 'help' | 'brew' | 'ignite' | 'rev' }
|
||||
| { readonly kind: 'clear' }
|
||||
| { readonly kind: 'close' };
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly id: CommandId;
|
||||
readonly input: string;
|
||||
readonly aliases: Record<AppLocale, readonly string[]>;
|
||||
readonly action: CommandAction;
|
||||
}
|
||||
|
||||
export type CommandMatch =
|
||||
| { readonly kind: 'command'; readonly definition: CommandDefinition }
|
||||
| { readonly kind: 'empty' }
|
||||
| { readonly kind: 'unknown'; readonly input: string };
|
||||
|
||||
export const COMMANDS: readonly CommandDefinition[] = [
|
||||
{
|
||||
id: 'help',
|
||||
input: 'help',
|
||||
aliases: { de: ['hilfe'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'help' },
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
input: 'projects',
|
||||
aliases: { de: ['projekte'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'projects' },
|
||||
},
|
||||
{
|
||||
id: 'servicesAi',
|
||||
input: 'services ai',
|
||||
aliases: { de: ['leistungen ai'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'servicesAi' },
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
input: 'cv',
|
||||
aliases: { de: ['lebenslauf'], en: ['resume'] },
|
||||
action: { kind: 'openCv' },
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
input: 'contact',
|
||||
aliases: { de: ['kontakt'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'contact' },
|
||||
},
|
||||
{
|
||||
id: 'brew',
|
||||
input: 'brew',
|
||||
aliases: { de: ['brauen'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'brew' },
|
||||
},
|
||||
{
|
||||
id: 'ignite',
|
||||
input: 'ignite',
|
||||
aliases: { de: ['zuenden', 'zünden'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'ignite' },
|
||||
},
|
||||
{
|
||||
id: 'rev',
|
||||
input: 'rev',
|
||||
aliases: { de: ['drehzahl'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'rev' },
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
input: 'clear',
|
||||
aliases: { de: ['leeren'], en: [] },
|
||||
action: { kind: 'clear' },
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
input: 'close',
|
||||
aliases: { de: ['schliessen', 'schließen'], en: [] },
|
||||
action: { kind: 'close' },
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_LOOKUP = buildCommandLookup(COMMANDS);
|
||||
|
||||
function buildCommandLookup(
|
||||
commands: readonly CommandDefinition[],
|
||||
): ReadonlyMap<string, CommandDefinition> {
|
||||
const lookup = new Map<string, CommandDefinition>();
|
||||
|
||||
for (const command of commands) {
|
||||
lookup.set(normalizeCommandInput(command.input), command);
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
lookup.set(normalizeCommandInput(alias), command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export function normalizeCommandInput(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function parseCommand(raw: string): CommandMatch {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
const definition = COMMAND_LOOKUP.get(normalized);
|
||||
|
||||
if (!definition) {
|
||||
return { kind: 'unknown', input: raw };
|
||||
}
|
||||
|
||||
return { kind: 'command', definition };
|
||||
}
|
||||
|
||||
export function suggestCommands(raw: string, locale: AppLocale): readonly CommandDefinition[] {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
return COMMANDS.filter((command) => {
|
||||
if (normalized.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.input.startsWith(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return command.aliases[locale].some((alias) =>
|
||||
normalizeCommandInput(alias).startsWith(normalized),
|
||||
);
|
||||
});
|
||||
}
|
||||
67
src/app/shared/contact-briefing/contact-briefing.html
Normal file
67
src/app/shared/contact-briefing/contact-briefing.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<div class="briefing stack">
|
||||
@for (field of copy().fields; track field.id) {
|
||||
<div class="field stack">
|
||||
<label [attr.for]="controlId(field)">
|
||||
{{ field.label }}
|
||||
@if (field.required) {
|
||||
<span class="required" aria-hidden="true">*</span>
|
||||
<span class="visually-hidden">{{ copy().requiredMarkerLabel }}</span>
|
||||
}
|
||||
</label>
|
||||
@if (field.hint) {
|
||||
<p class="hint" [id]="controlId(field) + '-hint'">{{ field.hint }}</p>
|
||||
}
|
||||
@switch (field.control) {
|
||||
@case ('textarea') {
|
||||
<textarea
|
||||
[id]="controlId(field)"
|
||||
[value]="values()[field.id]"
|
||||
[required]="field.required"
|
||||
[attr.aria-required]="field.required ? 'true' : null"
|
||||
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
|
||||
rows="6"
|
||||
(input)="onInput(field.id, $event)"
|
||||
></textarea>
|
||||
}
|
||||
@case ('select') {
|
||||
<select
|
||||
[id]="controlId(field)"
|
||||
[value]="values()[field.id]"
|
||||
[required]="field.required"
|
||||
[attr.aria-required]="field.required ? 'true' : null"
|
||||
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
|
||||
(input)="onInput(field.id, $event)"
|
||||
>
|
||||
<option value=""></option>
|
||||
@for (option of field.options ?? []; track option.value) {
|
||||
<option [value]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@default {
|
||||
<input
|
||||
[id]="controlId(field)"
|
||||
[type]="field.control"
|
||||
[value]="values()[field.id]"
|
||||
[required]="field.required"
|
||||
[attr.aria-required]="field.required ? 'true' : null"
|
||||
[attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null"
|
||||
(input)="onInput(field.id, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="incomplete" aria-live="polite">{{ incompleteMessage() }}</p>
|
||||
|
||||
<p class="note">{{ copy().noBackendNote }}</p>
|
||||
|
||||
<p class="cluster actions">
|
||||
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
|
||||
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||
@if (showCalendar()) {
|
||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
47
src/app/shared/contact-briefing/contact-briefing.scss
Normal file
47
src/app/shared/contact-briefing/contact-briefing.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
.briefing {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-accent-soft);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.hint,
|
||||
.incomplete,
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions a {
|
||||
color: var(--color-accent-cool);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.submit {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.actions a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
88
src/app/shared/contact-briefing/contact-briefing.spec.ts
Normal file
88
src/app/shared/contact-briefing/contact-briefing.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SITE_CONTENT_DATA } from '../../core/content/site-content';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { ContactBriefing } from './contact-briefing';
|
||||
|
||||
function setControl(root: HTMLElement, id: string, value: string): void {
|
||||
const control = root.querySelector<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
`#${id}`,
|
||||
);
|
||||
expect(control).toBeTruthy();
|
||||
control!.value = value;
|
||||
control!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
describe('ContactBriefing', () => {
|
||||
it('binds labels, builds an encoded mailto link and never talks to a server', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response());
|
||||
const xhrSpy = vi.spyOn(XMLHttpRequest.prototype, 'send').mockImplementation(() => undefined);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactBriefing],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(ContactBriefing);
|
||||
const copy = SITE_CONTENT_DATA.de.contact;
|
||||
fixture.componentRef.setInput('copy', copy);
|
||||
await fixture.whenStable();
|
||||
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
|
||||
for (const field of copy.fields) {
|
||||
const controlId = `contact-${field.id}`;
|
||||
const label = root.querySelector(`label[for="${controlId}"]`);
|
||||
const control = root.querySelector(`#${controlId}`);
|
||||
expect(label?.textContent).toContain(field.label);
|
||||
expect(control).toBeTruthy();
|
||||
}
|
||||
|
||||
expect(root.querySelector('form[action]')).toBeNull();
|
||||
expect(root.textContent).toContain(copy.incompleteHint);
|
||||
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
||||
expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
||||
expect(root.textContent).not.toContain(copy.calendarLabel);
|
||||
|
||||
const special = 'Äpfel & Birnen?\nPreis=100';
|
||||
setControl(root, 'contact-name', special);
|
||||
setControl(root, 'contact-email', 'team@example.com');
|
||||
setControl(root, 'contact-projectType', 'software');
|
||||
setControl(root, 'contact-situation', special);
|
||||
setControl(root, 'contact-timeframe', 'Q3');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(root.textContent).not.toContain(copy.incompleteHint);
|
||||
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
||||
|
||||
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
||||
expect(submit?.getAttribute('href')?.startsWith('mailto:info@antoniolede.de?subject=')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const href = submit?.getAttribute('href') ?? '';
|
||||
expect(href.includes(' ')).toBe(false);
|
||||
expect(href.includes('\n')).toBe(false);
|
||||
|
||||
const query = href.slice(href.indexOf('?') + 1);
|
||||
const [subjectPart, bodyPart] = query.split('&body=');
|
||||
expect(subjectPart.startsWith('subject=')).toBe(true);
|
||||
expect(subjectPart.includes('&')).toBe(false);
|
||||
expect(bodyPart.includes('&')).toBe(false);
|
||||
|
||||
const decodedBody = decodeURIComponent(bodyPart);
|
||||
expect(decodedBody).toContain(special);
|
||||
expect(decodedBody).toContain('team@example.com');
|
||||
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||
const projectTypeLabel = projectType?.options?.find(
|
||||
(option) => option.value === 'software',
|
||||
)?.label;
|
||||
expect(projectTypeLabel).toBeTruthy();
|
||||
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);
|
||||
const company = copy.fields.find((field) => field.id === 'company');
|
||||
expect(company).toBeTruthy();
|
||||
expect(decodedBody).not.toContain(`${company!.label}:`);
|
||||
expect(decodeURIComponent(subjectPart.slice('subject='.length))).toBe(copy.mailSubject);
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(xhrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
84
src/app/shared/contact-briefing/contact-briefing.ts
Normal file
84
src/app/shared/contact-briefing/contact-briefing.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
|
||||
import {
|
||||
type ContactFieldCopy,
|
||||
type ContactFieldId,
|
||||
type ContactPageCopy,
|
||||
} from '../../core/content/content.contracts';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
|
||||
const EMPTY_VALUES: Record<ContactFieldId, string> = {
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
projectType: '',
|
||||
situation: '',
|
||||
timeframe: '',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-contact-briefing',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './contact-briefing.html',
|
||||
styleUrl: './contact-briefing.scss',
|
||||
})
|
||||
export class ContactBriefing {
|
||||
readonly copy = input.required<ContactPageCopy>();
|
||||
|
||||
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
||||
protected readonly contactEmail = SITE_CONFIG.contactEmail;
|
||||
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
||||
|
||||
protected readonly missingLabels = computed(() => {
|
||||
const copy = this.copy();
|
||||
const values = this.values();
|
||||
return copy.fields
|
||||
.filter((field) => field.required && values[field.id].trim().length === 0)
|
||||
.map((field) => field.label);
|
||||
});
|
||||
|
||||
protected readonly incompleteMessage = computed(() => {
|
||||
const missing = this.missingLabels();
|
||||
if (missing.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${this.copy().incompleteHint} ${missing.join(', ')}`;
|
||||
});
|
||||
|
||||
protected readonly mailtoHref = computed(() => {
|
||||
const copy = this.copy();
|
||||
const values = this.values();
|
||||
const body = this.assembleBody(copy, values);
|
||||
return `mailto:${SITE_CONFIG.contactEmail}?subject=${encodeURIComponent(copy.mailSubject)}&body=${encodeURIComponent(body)}`;
|
||||
});
|
||||
|
||||
protected readonly showCalendar = computed(() => {
|
||||
const url = SITE_CONFIG.calendarUrl;
|
||||
return typeof url === 'string' && url.length > 0;
|
||||
});
|
||||
|
||||
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
||||
}
|
||||
|
||||
protected controlId(field: ContactFieldCopy): string {
|
||||
return `contact-${field.id}`;
|
||||
}
|
||||
|
||||
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
||||
const lines = copy.fields.flatMap((field) => {
|
||||
const raw = values[field.id];
|
||||
if (!field.required && raw.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [`${field.label}: ${this.displayValue(field, raw)}`];
|
||||
});
|
||||
return [copy.mailIntro, '', ...lines, '', copy.mailSignature].join('\n');
|
||||
}
|
||||
|
||||
private displayValue(field: ContactFieldCopy, value: string): string {
|
||||
return field.options?.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
}
|
||||
6
src/app/shared/content-section/content-section.html
Normal file
6
src/app/shared/content-section/content-section.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<section class="content-section stack" [attr.aria-labelledby]="headingId()">
|
||||
<h2 [id]="headingId()">{{ section().headline }}</h2>
|
||||
@for (paragraph of section().body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
16
src/app/shared/content-section/content-section.scss
Normal file
16
src/app/shared/content-section/content-section.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
14
src/app/shared/content-section/content-section.ts
Normal file
14
src/app/shared/content-section/content-section.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { type SectionCopy } from '../../core/content/content.contracts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-section',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './content-section.html',
|
||||
styleUrl: './content-section.scss',
|
||||
})
|
||||
export class ContentSection {
|
||||
readonly section = input.required<SectionCopy>();
|
||||
|
||||
protected readonly headingId = computed(() => `section-${this.section().id}`);
|
||||
}
|
||||
16
src/app/shared/cta-row/cta-row.html
Normal file
16
src/app/shared/cta-row/cta-row.html
Normal file
@@ -0,0 +1,16 @@
|
||||
@if (ctas().length > 0) {
|
||||
<p class="cta-row cluster">
|
||||
@for (cta of ctas(); track $index) {
|
||||
@if (cta.routeId) {
|
||||
<a [routerLink]="link(cta)" [fragment]="cta.fragment ?? undefined">{{ cta.label }}</a>
|
||||
} @else if (cta.href) {
|
||||
<a
|
||||
[href]="cta.href"
|
||||
[attr.rel]="cta.external ? 'noopener noreferrer' : null"
|
||||
[attr.target]="cta.external ? '_blank' : null"
|
||||
>{{ cta.label }}</a
|
||||
>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
15
src/app/shared/cta-row/cta-row.scss
Normal file
15
src/app/shared/cta-row/cta-row.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.cta-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent-cool);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a:hover {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
}
|
||||
21
src/app/shared/cta-row/cta-row.ts
Normal file
21
src/app/shared/cta-row/cta-row.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { type CtaCopy } from '../../core/content/content.contracts';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cta-row',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
templateUrl: './cta-row.html',
|
||||
styleUrl: './cta-row.scss',
|
||||
})
|
||||
export class CtaRow {
|
||||
readonly ctas = input<readonly CtaCopy[]>([]);
|
||||
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected link(cta: CtaCopy): unknown[] {
|
||||
return cta.routeId ? this.navigation.link(cta.routeId) : [];
|
||||
}
|
||||
}
|
||||
10
src/app/shared/legal-review-notice/legal-review-notice.html
Normal file
10
src/app/shared/legal-review-notice/legal-review-notice.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<aside class="legal-review" role="note">
|
||||
<p>{{ notice() }}</p>
|
||||
@if (todos().length > 0) {
|
||||
<ul>
|
||||
@for (todo of todos(); track $index) {
|
||||
<li>{{ todo }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</aside>
|
||||
21
src/app/shared/legal-review-notice/legal-review-notice.scss
Normal file
21
src/app/shared/legal-review-notice/legal-review-notice.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.legal-review {
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-accent-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-overlay);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
li {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
12
src/app/shared/legal-review-notice/legal-review-notice.ts
Normal file
12
src/app/shared/legal-review-notice/legal-review-notice.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-legal-review-notice',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './legal-review-notice.html',
|
||||
styleUrl: './legal-review-notice.scss',
|
||||
})
|
||||
export class LegalReviewNotice {
|
||||
readonly notice = input.required<string>();
|
||||
readonly todos = input<readonly string[]>([]);
|
||||
}
|
||||
15
src/app/shared/metric-list/metric-list.html
Normal file
15
src/app/shared/metric-list/metric-list.html
Normal file
@@ -0,0 +1,15 @@
|
||||
@if (metrics().length > 0) {
|
||||
<dl class="metric-list" [attr.aria-labelledby]="labelledBy()">
|
||||
@for (metric of metrics(); track metric.id) {
|
||||
<div class="metric glass-surface">
|
||||
<dt>{{ metric.value }}</dt>
|
||||
<dd>
|
||||
<span>{{ metric.label }}</span>
|
||||
@if (metric.note) {
|
||||
<small>{{ metric.note }}</small>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
}
|
||||
29
src/app/shared/metric-list/metric-list.scss
Normal file
29
src/app/shared/metric-list/metric-list.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.metric-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metric {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
13
src/app/shared/metric-list/metric-list.ts
Normal file
13
src/app/shared/metric-list/metric-list.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { type MetricCopy } from '../../core/content/content.contracts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metric-list',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './metric-list.html',
|
||||
styleUrl: './metric-list.scss',
|
||||
})
|
||||
export class MetricList {
|
||||
readonly metrics = input<readonly MetricCopy[]>([]);
|
||||
readonly labelledBy = input<string | null>(null);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
@mixin reveal-target {
|
||||
&.reveal-pending {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
transition:
|
||||
opacity var(--duration-base) var(--ease-standard),
|
||||
transform var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
&.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.reveal-pending,
|
||||
&.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<div class="metric-bar">
|
||||
<div class="metric-bar-header">
|
||||
<span [id]="labelId">{{ label() }}</span>
|
||||
<span>{{ displayValue() }}</span>
|
||||
</div>
|
||||
@if (description(); as descriptionText) {
|
||||
<p class="metric-bar-description">{{ descriptionText }}</p>
|
||||
}
|
||||
<div
|
||||
class="metric-bar-track"
|
||||
role="progressbar"
|
||||
[attr.aria-valuenow]="clampedValue()"
|
||||
aria-valuemin="0"
|
||||
[attr.aria-valuemax]="max()"
|
||||
[attr.aria-valuetext]="displayValue()"
|
||||
[attr.aria-labelledby]="labelId"
|
||||
>
|
||||
<div class="metric-bar-fill" [style.width.%]="percent()"></div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user