Compare commits
13 Commits
orchestrat
...
55d012cc10
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d012cc10 | |||
| 2635d0c639 | |||
| b95dbd8e1a | |||
| e63b4edade | |||
| b8b9e57d06 | |||
| 19d00da9bb | |||
| 35a575dba9 | |||
| 255f0eace6 | |||
| 359e887a7a | |||
| 5921fc5ede | |||
| 162bd790da | |||
| 646b39b200 | |||
| 86d6dd085d |
@@ -4,7 +4,7 @@ Contributor and agent guide for this repository. Read this before changing routi
|
||||
|
||||
## Purpose
|
||||
|
||||
This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales.
|
||||
This is a bilingual portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Home is the direct-customer experience. `/pitch` is the recruiter-oriented page and is reachable without sitting in the primary navigation. Every public page exists in both locales.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -93,6 +93,7 @@ German and English are written idiomatically per language, never machine-transla
|
||||
## i18n rules
|
||||
|
||||
- German at `/`, English under `/en`
|
||||
- Primary navigation is Home, Services (with four children), Projects, About and Contact. Stack and Pitch stay reachable from content and the terminal, not from the primary nav.
|
||||
- Every route id exists in both locales
|
||||
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
|
||||
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
|
||||
@@ -154,7 +155,7 @@ A change is not done until:
|
||||
| ----------- | -------------------------------------------------------------------------------- |
|
||||
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
|
||||
| Content | Bilingual copy data and page composition |
|
||||
| Signature | Systems Map, terminal, dot background and motion |
|
||||
| Signature | Systems Map, terminal dock, dot background and motion |
|
||||
| Integration | SEO and cross-cutting a11y/performance hardening |
|
||||
|
||||
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.
|
||||
|
||||
39
README.md
39
README.md
@@ -1,6 +1,6 @@
|
||||
# Portfolio
|
||||
|
||||
Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`.
|
||||
Bilingual portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`. Home speaks to small-company customers. `/pitch` is the recruiter-oriented page and is not in the primary navigation.
|
||||
|
||||
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
|
||||
|
||||
@@ -17,23 +17,24 @@ npm install
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------- |
|
||||
| `npm start` | Start the Angular dev server. |
|
||||
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
||||
| `npm run watch` | Development rebuild on change. |
|
||||
| `npm test` | Unit tests in watch mode. |
|
||||
| `npm run test:ci` | Unit tests once; exits when finished. |
|
||||
| `npm run lint` | Run ESLint. |
|
||||
| `npm run lint:fix` | Run ESLint with autofix. |
|
||||
| `npm run format` | Format the workspace with Prettier. |
|
||||
| `npm run format:check` | Check formatting without writing. |
|
||||
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
||||
| `npm run e2e` | Playwright + axe-core against the production SSR bundle. |
|
||||
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
|
||||
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
|
||||
| `npm run ci` | lint, format check, tests, then production build. |
|
||||
| Script | Description |
|
||||
| ----------------------------- | -------------------------------------------------------------------------- |
|
||||
| `npm start` | Start the Angular dev server. |
|
||||
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
|
||||
| `npm run watch` | Development rebuild on change. |
|
||||
| `npm test` | Unit tests in watch mode. |
|
||||
| `npm run test:ci` | Unit tests once; exits when finished. |
|
||||
| `npm run lint` | Run ESLint. |
|
||||
| `npm run lint:fix` | Run ESLint with autofix. |
|
||||
| `npm run format` | Format the workspace with Prettier. |
|
||||
| `npm run format:check` | Check formatting without writing. |
|
||||
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
|
||||
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
|
||||
| `npm run e2e` | Playwright + axe-core against the production SSR bundle. |
|
||||
| `npm run e2e:install` | Install the pinned Chromium build for Playwright. |
|
||||
| `npm run lighthouse` | Production build, then Lighthouse CI (`lighthouserc.json`). |
|
||||
| `npm run ci` | lint, format check, tests, then production build. |
|
||||
| `npm run cv:link` | Add the visible `/pitch` URL and link annotation to `CV.pdf` (idempotent). |
|
||||
|
||||
## Local SSR build
|
||||
|
||||
@@ -42,7 +43,7 @@ npm run build
|
||||
npm run serve:ssr
|
||||
```
|
||||
|
||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
|
||||
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`. The header no longer offers that download; Pitch and the terminal `navigate cv` command still do. The lower-right terminal dock replaces the old centered command palette (`Ctrl+K` / `⌘K`).
|
||||
|
||||
`npm run e2e` builds the SSR bundle and serves it on port 4173. `npm run lighthouse` builds, then starts the SSR server on port 4000. Browser binaries and generated reports (`test-results`, `playwright-report`, `.lighthouseci`) are not committed.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { pagePath } from './helpers';
|
||||
const AXE_PATHS = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
@@ -23,8 +25,11 @@ test.describe('accessibility', () => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
const pending = page.locator('.reveal-pending');
|
||||
await expect(pending).toHaveCount(0);
|
||||
await expect(page.locator('app-systems-map')).toBeVisible();
|
||||
await expect(page.locator('app-case-card').first()).toBeVisible();
|
||||
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
await expect(page.locator('.reveal-pending')).toHaveCount(0);
|
||||
await expect(page.locator('app-systems-map')).toBeVisible();
|
||||
});
|
||||
|
||||
for (const path of AXE_PATHS) {
|
||||
|
||||
@@ -24,9 +24,13 @@ test.describe('contact briefing', () => {
|
||||
|
||||
await page.locator('#contact-name').fill('Ada');
|
||||
await page.locator('#contact-email').fill('ada@example.com');
|
||||
await page.locator('#contact-projectType').selectOption('software');
|
||||
await page.locator('#contact-situation').fill('Need a migration.');
|
||||
|
||||
const optionalHref = await page.locator('a.submit').getAttribute('href');
|
||||
expect(optionalHref).toMatch(/^mailto:/);
|
||||
|
||||
await page.locator('#contact-projectType').selectOption('unsure');
|
||||
|
||||
const href = await page.locator('a.submit').getAttribute('href');
|
||||
expect(href).toMatch(/^mailto:/);
|
||||
expect(href).toContain(encodeURIComponent(SITE_CONTENT_DATA.de.contact.mailSubject));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { pagePath } from './helpers';
|
||||
|
||||
test.describe('keyboard and palette', () => {
|
||||
test.describe('keyboard and terminal dock', () => {
|
||||
test('skip link is first and moves focus to main', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => {
|
||||
const headerBox = await page.locator('.site-header').boundingBox();
|
||||
expect(headerBox, 'header while submenu is open').toBeTruthy();
|
||||
expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200);
|
||||
|
||||
const background = await firstChild.evaluate((element) => {
|
||||
const submenu = element.closest('.submenu');
|
||||
return submenu ? getComputedStyle(submenu).backgroundColor : '';
|
||||
});
|
||||
expect(background, 'submenu background must be fully opaque').toMatch(
|
||||
/^rgb\(\d+,\s*\d+,\s*\d+\)$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({
|
||||
test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const trigger = page.locator('.command-palette-trigger');
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Control+k');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('.site')).toHaveAttribute('inert', '');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
|
||||
|
||||
const focusable = dialog.locator(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
const count = await focusable.count();
|
||||
const first = focusable.first();
|
||||
const last = focusable.nth(count - 1);
|
||||
await last.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(first).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(last).toBeFocused();
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
|
||||
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { expectNoOverflow, pagePath } from './helpers';
|
||||
const CHECKED = [
|
||||
pagePath('home', 'de'),
|
||||
pagePath('home', 'en'),
|
||||
pagePath('pitch', 'de'),
|
||||
pagePath('pitch', 'en'),
|
||||
pagePath('projects', 'de'),
|
||||
pagePath('projects', 'en'),
|
||||
pagePath('servicesAi', 'de'),
|
||||
|
||||
@@ -38,6 +38,25 @@ test.describe('server-rendered metadata', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('pitch has full head metadata and stays out of the primary nav', async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
await expectHead(request, 'pitch', 'de');
|
||||
await expectHead(request, 'pitch', 'en');
|
||||
|
||||
const sitemap = await request.get('/sitemap.xml');
|
||||
const xml = await sitemap.text();
|
||||
expect(xml).toContain('https://antoniolede.de/pitch');
|
||||
expect(xml).toContain('https://antoniolede.de/en/pitch');
|
||||
|
||||
await page.goto('/');
|
||||
await ensurePrimaryNavOpen(page);
|
||||
const nav = page.locator('.primary-nav');
|
||||
await expect(nav.locator(`a[href="${pagePath('pitch', 'de')}"]`)).toHaveCount(0);
|
||||
await expect(nav.locator(`a[href="${pagePath('home', 'de')}"]`)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('unmatched URLs return 404 and a real route stays 200', async ({ request }) => {
|
||||
const home = await request.get('/');
|
||||
expect(home.status(), 'GET /').toBe(200);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pagePath } from './helpers';
|
||||
|
||||
test.describe('Systems Map', () => {
|
||||
test('keeps the card list visible and shows the SVG from 1024px', async ({ page }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
|
||||
for (const width of [390, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -21,7 +21,7 @@ test.describe('Systems Map', () => {
|
||||
});
|
||||
|
||||
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
|
||||
for (const width of [1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
@@ -51,7 +51,7 @@ test.describe('Systems Map', () => {
|
||||
});
|
||||
|
||||
test('every SVG node points at a real route', async ({ page, request }) => {
|
||||
await page.goto(pagePath('home', 'de'));
|
||||
await page.goto(pagePath('services', 'de'));
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const hrefs = await page
|
||||
.locator('.systems-map-node')
|
||||
|
||||
145
e2e/terminal.e2e.ts
Normal file
145
e2e/terminal.e2e.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
|
||||
|
||||
test.describe('terminal dock', () => {
|
||||
test('walks collapsed, expanded and maximized and runs the grammar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const copy = SIGNATURE_COPY.de.terminal;
|
||||
await page.goto('/');
|
||||
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
const input = page.locator('#terminal-dock-input');
|
||||
const log = page.locator('.terminal-dock-log');
|
||||
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
|
||||
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
|
||||
|
||||
if (testInfo.project.name === 'mobile') {
|
||||
const box = await panel.boundingBox();
|
||||
expect(box, 'bottom-sheet panel').toBeTruthy();
|
||||
expect(box!.width).toBeGreaterThan(300);
|
||||
} else {
|
||||
await maximize.click();
|
||||
await expect(maximize).toHaveAttribute('aria-pressed', 'true');
|
||||
await maximize.click();
|
||||
await expect(maximize).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
|
||||
await input.fill('navigate pitch');
|
||||
await input.press('Enter');
|
||||
await page.waitForURL('**/pitch');
|
||||
await expect(page).toHaveURL(/\/pitch$/);
|
||||
await expect(log).toContainText(`${copy.prompt} navigate pitch`);
|
||||
|
||||
await input.press('ArrowUp');
|
||||
await expect(input).toHaveValue('navigate pitch');
|
||||
|
||||
await input.fill('history');
|
||||
await input.press('Enter');
|
||||
await expect(log).toContainText(copy.historyIntro);
|
||||
await expect(log).toContainText('navigate pitch');
|
||||
|
||||
await input.fill('clear');
|
||||
await input.press('Enter');
|
||||
await expect(log).toHaveText(copy.clearedMessage);
|
||||
|
||||
await input.fill('navigate p');
|
||||
await input.press('Tab');
|
||||
await expect(log).toContainText('pitch');
|
||||
await expect(log).toContainText('projects');
|
||||
|
||||
await input.fill('navigate nowhere');
|
||||
await input.press('Enter');
|
||||
await expect(log).toContainText(copy.validTargetsLabel);
|
||||
await expect(log).toContainText('pitch');
|
||||
|
||||
await input.fill('nav');
|
||||
await input.press('Tab');
|
||||
await expect(input).toHaveValue('navigate');
|
||||
});
|
||||
|
||||
test('clicking the visible desktop trigger while open collapses the panel', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === 'mobile',
|
||||
'Below md the trigger is hidden while the panel is open.',
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
|
||||
const copy = SIGNATURE_COPY.de.terminal;
|
||||
await page.goto('/');
|
||||
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
const input = page.locator('#terminal-dock-input');
|
||||
const log = page.locator('.terminal-dock-log');
|
||||
|
||||
await trigger.click();
|
||||
await expect(log).toBeVisible();
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await input.fill('help');
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
log.evaluate((element) => {
|
||||
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const last = log.locator('p').last();
|
||||
const logBox = await log.boundingBox();
|
||||
const lastBox = await last.boundingBox();
|
||||
expect(logBox && lastBox).toBeTruthy();
|
||||
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1);
|
||||
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1);
|
||||
});
|
||||
|
||||
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
|
||||
page,
|
||||
}) => {
|
||||
const copy = SIGNATURE_COPY.de.terminal;
|
||||
await page.goto('/');
|
||||
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const collapse = page.getByRole('button', { name: copy.collapseLabel });
|
||||
await collapse.focus();
|
||||
await expect(collapse).toBeFocused();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
"url": [
|
||||
"http://127.0.0.1:4000/",
|
||||
"http://127.0.0.1:4000/en",
|
||||
"http://127.0.0.1:4000/pitch",
|
||||
"http://127.0.0.1:4000/en/pitch",
|
||||
"http://127.0.0.1:4000/leistungen/ai-integration",
|
||||
"http://127.0.0.1:4000/en/projects"
|
||||
],
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -27,7 +27,7 @@
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20.17.19",
|
||||
"angular-eslint": "^21.4.0",
|
||||
@@ -35,6 +35,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
@@ -4285,6 +4286,26 @@
|
||||
"third-party-web": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
@@ -10888,6 +10909,13 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -11064,6 +11092,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build",
|
||||
"e2e": "playwright test",
|
||||
"e2e:install": "playwright install chromium",
|
||||
"lighthouse": "npm run build && lhci autorun"
|
||||
"lighthouse": "npm run build && lhci autorun",
|
||||
"cv:link": "node scripts/add-cv-pitch-link.mjs"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
@@ -61,6 +62,7 @@
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"jsdom": "^27.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Antonio Ledebuhr
|
||||
|
||||
Fullstack and DevOps engineer in Tangermünde. The public site documents a curated set of stations, four service areas, and a local mailto briefing. German is the default language; English is the full second version.
|
||||
Fullstack and DevOps engineer in Tangermünde. The public site documents six public cases, one services page with four anchored areas, and a briefing form that only opens a local mail client. German is the default language; English is the full second version.
|
||||
|
||||
## Profile
|
||||
|
||||
- https://antoniolede.de/
|
||||
- https://antoniolede.de/en
|
||||
- https://antoniolede.de/pitch — Recruiter-Profil / recruiter profile
|
||||
- https://antoniolede.de/en/pitch — Recruiter profile / Recruiter-Profil
|
||||
- https://antoniolede.de/ueber-mich
|
||||
- https://antoniolede.de/en/about
|
||||
|
||||
@@ -13,14 +15,6 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
|
||||
|
||||
- https://antoniolede.de/leistungen
|
||||
- https://antoniolede.de/en/services
|
||||
- https://antoniolede.de/leistungen/software
|
||||
- https://antoniolede.de/en/services/software
|
||||
- https://antoniolede.de/leistungen/hardware-netzwerk
|
||||
- https://antoniolede.de/en/services/hardware-network
|
||||
- https://antoniolede.de/leistungen/cluster
|
||||
- https://antoniolede.de/en/services/clusters
|
||||
- https://antoniolede.de/leistungen/ai-integration
|
||||
- https://antoniolede.de/en/services/ai-integration
|
||||
|
||||
## Projects
|
||||
|
||||
@@ -30,10 +24,14 @@ Fullstack and DevOps engineer in Tangermünde. The public site documents a curat
|
||||
- https://antoniolede.de/en/projects#innofocus
|
||||
- https://antoniolede.de/projekte#roesterei
|
||||
- https://antoniolede.de/en/projects#roesterei
|
||||
- https://antoniolede.de/projekte#myspa
|
||||
- https://antoniolede.de/en/projects#myspa
|
||||
- https://antoniolede.de/projekte#bannier
|
||||
- https://antoniolede.de/en/projects#bannier
|
||||
- https://antoniolede.de/projekte#elbwaerme
|
||||
- https://antoniolede.de/en/projects#elbwaerme
|
||||
- https://antoniolede.de/projekte#hdi
|
||||
- https://antoniolede.de/en/projects#hdi
|
||||
- https://antoniolede.de/projekte#devdays
|
||||
- https://antoniolede.de/en/projects#devdays
|
||||
|
||||
## Contact, stack and legal
|
||||
|
||||
|
||||
@@ -6,36 +6,18 @@
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/pitch</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/software</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/hardware-netzwerk</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/cluster</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/ai-integration</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/projekte</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
|
||||
@@ -78,36 +60,18 @@
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/pitch</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/pitch" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/pitch" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/pitch" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/software</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/hardware-network</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/clusters</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/ai-integration</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/projects</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
|
||||
|
||||
123
scripts/add-cv-pitch-link.mjs
Normal file
123
scripts/add-cv-pitch-link.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PDFDocument, PDFName, PDFString, rgb, StandardFonts } from 'pdf-lib';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CV_PATH = join(ROOT, 'CV.pdf');
|
||||
const PITCH_URI = 'https://antoniolede.de/pitch';
|
||||
const VISIBLE_TEXT = 'antoniolede.de/pitch';
|
||||
const TEXT_X = 59.6;
|
||||
const TEXT_Y = 215.4;
|
||||
const FONT_SIZE = 10.5;
|
||||
const PADDING = 2;
|
||||
const ORIGINAL_TITLE = 'CV';
|
||||
const ORIGINAL_CREATOR = 'Pages';
|
||||
// Producer from the unmodified Pages/Quartz export of CV.pdf.
|
||||
const ORIGINAL_PRODUCER = 'macOS Version 26.5.2 (Build 25F84) Quartz PDFContext';
|
||||
|
||||
function asString(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value.decodeText === 'function') {
|
||||
return value.decodeText();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function annotationUri(annotation) {
|
||||
const action = annotation.lookup(PDFName.of('A'));
|
||||
if (!action || typeof action.lookup !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const uri = action.lookup(PDFName.of('URI'));
|
||||
return asString(uri);
|
||||
}
|
||||
|
||||
function pageHasPitchLink(page) {
|
||||
const annots = page.node.lookup(PDFName.of('Annots'));
|
||||
if (!annots || typeof annots.size !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < annots.size(); index += 1) {
|
||||
const annotation = annots.lookup(index);
|
||||
if (annotationUri(annotation) === PITCH_URI) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOriginalMetadata(pdfDoc) {
|
||||
return (
|
||||
pdfDoc.getTitle() === ORIGINAL_TITLE &&
|
||||
pdfDoc.getCreator() === ORIGINAL_CREATOR &&
|
||||
pdfDoc.getProducer() === ORIGINAL_PRODUCER
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = readFileSync(CV_PATH);
|
||||
const pdfDoc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const page = pdfDoc.getPage(0);
|
||||
const needsLink = !pageHasPitchLink(page);
|
||||
const needsMetadata = !hasOriginalMetadata(pdfDoc);
|
||||
|
||||
if (!needsLink && !needsMetadata) {
|
||||
console.log('Pitch link and original metadata already present; leaving CV.pdf unchanged.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (needsLink) {
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const textWidth = font.widthOfTextAtSize(VISIBLE_TEXT, FONT_SIZE);
|
||||
|
||||
page.drawText(VISIBLE_TEXT, {
|
||||
x: TEXT_X,
|
||||
y: TEXT_Y,
|
||||
size: FONT_SIZE,
|
||||
font,
|
||||
color: rgb(54 / 255, 125 / 255, 162 / 255),
|
||||
});
|
||||
|
||||
const link = pdfDoc.context.register(
|
||||
pdfDoc.context.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [
|
||||
TEXT_X - PADDING,
|
||||
TEXT_Y - PADDING,
|
||||
TEXT_X + textWidth + PADDING,
|
||||
TEXT_Y + FONT_SIZE + PADDING,
|
||||
],
|
||||
Border: [0, 0, 0],
|
||||
A: {
|
||||
S: 'URI',
|
||||
URI: PDFString.of(PITCH_URI),
|
||||
},
|
||||
}),
|
||||
);
|
||||
page.node.addAnnot(link);
|
||||
}
|
||||
|
||||
if (needsMetadata) {
|
||||
pdfDoc.setTitle(ORIGINAL_TITLE);
|
||||
pdfDoc.setCreator(ORIGINAL_CREATOR);
|
||||
pdfDoc.setProducer(ORIGINAL_PRODUCER);
|
||||
}
|
||||
|
||||
const saved = await pdfDoc.save({ useObjectStreams: false });
|
||||
writeFileSync(CV_PATH, saved);
|
||||
|
||||
if (needsLink && needsMetadata) {
|
||||
console.log('Added the pitch link and restored the original CV.pdf metadata.');
|
||||
} else if (needsLink) {
|
||||
console.log('Added visible pitch URL and link annotation to page 1 of CV.pdf.');
|
||||
} else {
|
||||
console.log('Restored the original Title, Creator and Producer on CV.pdf.');
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
|
||||
<app-dot-background aria-hidden="true"></app-dot-background>
|
||||
<div class="site" [class.nav-collapsed]="!navOpen()" [attr.inert]="palette.open() ? '' : null">
|
||||
<div class="site" [class.nav-collapsed]="!navOpen()">
|
||||
<header class="site-header">
|
||||
<div class="content-container site-header-inner glass-surface">
|
||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||
{{ siteConfig.personName }}
|
||||
</a>
|
||||
<div class="site-toolbar cluster">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
@@ -26,25 +25,10 @@
|
||||
<a
|
||||
[routerLink]="item.link"
|
||||
routerLinkActive="is-active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[routerLinkActiveOptions]="{ exact: item.routeId === 'home' }"
|
||||
ariaCurrentWhenActive="page"
|
||||
>{{ item.label }}</a
|
||||
>
|
||||
@if (item.children; as children) {
|
||||
<ul class="submenu">
|
||||
@for (child of children; track child.routeId) {
|
||||
<li>
|
||||
<a
|
||||
[routerLink]="child.link"
|
||||
routerLinkActive="is-active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
ariaCurrentWhenActive="page"
|
||||
>{{ child.label }}</a
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -58,14 +42,6 @@
|
||||
>
|
||||
{{ shell().otherLocaleName }}
|
||||
</a>
|
||||
<a
|
||||
[href]="siteConfig.cvAssetPath"
|
||||
[attr.download]="siteConfig.cvDownloadFileName"
|
||||
type="application/pdf"
|
||||
>
|
||||
{{ shell().cvLabel }}
|
||||
</a>
|
||||
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -94,4 +70,4 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
<app-terminal-dock></app-terminal-dock>
|
||||
|
||||
@@ -9,7 +9,10 @@ describe('server routes', () => {
|
||||
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
|
||||
|
||||
const prerendered = serverRoutes.filter((route) => route.path !== '**');
|
||||
expect(prerendered.length).toBeGreaterThan(0);
|
||||
expect(prerendered).toHaveLength(26);
|
||||
expect(prerendered.map((route) => route.path)).toEqual(
|
||||
expect.arrayContaining(['pitch', 'en/pitch']),
|
||||
);
|
||||
|
||||
for (const route of prerendered) {
|
||||
expect(route.renderMode, route.path).toBe(RenderMode.Prerender);
|
||||
|
||||
@@ -64,6 +64,17 @@ describe('app routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('registers pitch as a lazy route in both locales', () => {
|
||||
const flattened = flattenRoutes(routes);
|
||||
const german = flattened.find((entry) => entry.path === 'pitch');
|
||||
const english = flattened.find((entry) => entry.path === 'en/pitch');
|
||||
const pitchRoute = routes.find((route) => route.path === 'pitch');
|
||||
|
||||
expect(german?.data).toEqual({ routeId: 'pitch', locale: 'de' });
|
||||
expect(english?.data).toEqual({ routeId: 'pitch', locale: 'en' });
|
||||
expect(pitchRoute?.loadComponent).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('attaches the final page title for every locale route', () => {
|
||||
const flattened = flattenRoutes(routes);
|
||||
|
||||
|
||||
@@ -11,19 +11,8 @@ type LazyPage = () => Promise<Type<unknown>>;
|
||||
|
||||
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
|
||||
home: () => import('./features/home/home').then((module) => module.HomePage),
|
||||
pitch: () => import('./features/pitch/pitch').then((module) => module.PitchPage),
|
||||
services: () => import('./features/services/services').then((module) => module.ServicesPage),
|
||||
servicesSoftware: () =>
|
||||
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
|
||||
servicesHardwareNetwork: () =>
|
||||
import('./features/services/hardware-network/hardware-network').then(
|
||||
(module) => module.ServicesHardwareNetworkPage,
|
||||
),
|
||||
servicesClusters: () =>
|
||||
import('./features/services/clusters/clusters').then((module) => module.ServicesClustersPage),
|
||||
servicesAi: () =>
|
||||
import('./features/services/ai-integration/ai-integration').then(
|
||||
(module) => module.ServicesAiPage,
|
||||
),
|
||||
projects: () => import('./features/projects/projects').then((module) => module.ProjectsPage),
|
||||
stack: () => import('./features/stack/stack').then((module) => module.StackPage),
|
||||
about: () => import('./features/about/about').then((module) => module.AboutPage),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.site-header {
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
align-items: center;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
@@ -114,17 +114,6 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.primary-nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.contact-cta {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.is-active {
|
||||
color: var(--color-accent-cool);
|
||||
}
|
||||
@@ -157,7 +146,8 @@
|
||||
}
|
||||
|
||||
@include bp.respond-to(md) {
|
||||
.nav-toggle {
|
||||
.nav-toggle,
|
||||
.site-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -166,28 +156,22 @@
|
||||
}
|
||||
|
||||
.site-header-inner {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-2);
|
||||
}
|
||||
|
||||
.site-nav,
|
||||
.nav-collapsed .site-nav {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.nav-collapsed .site-nav,
|
||||
.site-actions,
|
||||
.nav-collapsed .site-actions {
|
||||
display: flex;
|
||||
grid-column: auto;
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.site-toolbar {
|
||||
order: 2;
|
||||
.site-nav {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primary-nav {
|
||||
@@ -197,34 +181,7 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.primary-nav > li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.primary-nav > li > a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primary-nav > li > .submenu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: max-content;
|
||||
z-index: 20;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-glass);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.primary-nav > li:hover > .submenu,
|
||||
.primary-nav > li:focus-within > .submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,27 +89,25 @@ describe('App', () => {
|
||||
expect(compiled.querySelector('footer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the palette trigger in the header and the dialog outside .site', async () => {
|
||||
it('renders the terminal dock outside .site and keeps the header free of a CV link', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const site = compiled.querySelector('.site');
|
||||
const trigger = compiled.querySelector('header .command-palette-trigger');
|
||||
const dock = compiled.querySelector('app-terminal-dock');
|
||||
const trigger = compiled.querySelector('.terminal-dock-trigger');
|
||||
|
||||
expect(site).toBeTruthy();
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(site?.contains(trigger)).toBe(true);
|
||||
expect(dock).toBeTruthy();
|
||||
expect(site?.contains(dock)).toBe(false);
|
||||
expect(compiled.querySelector('header a[href="/cv/CV.pdf"]')).toBeNull();
|
||||
expect(compiled.querySelector('header .command-palette-trigger')).toBeNull();
|
||||
|
||||
(trigger as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const dialog = compiled.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(site?.contains(dialog)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(true);
|
||||
const panel = compiled.querySelector('.terminal-dock-panel');
|
||||
expect(panel).toBeTruthy();
|
||||
expect((trigger as HTMLButtonElement).hasAttribute('hidden')).toBe(true);
|
||||
expect(site?.contains(panel)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {
|
||||
|
||||
@@ -18,9 +18,7 @@ import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { NavigationService } from './core/navigation/navigation.service';
|
||||
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
|
||||
import { TerminalDock } from './shared/terminal/terminal-dock';
|
||||
|
||||
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
|
||||
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@@ -28,21 +26,13 @@ export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
DotBackground,
|
||||
CommandPalette,
|
||||
CommandPaletteTrigger,
|
||||
],
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly palette = inject(CommandPaletteService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition:
|
||||
transform var(--duration-base) var(--ease-standard),
|
||||
box-shadow var(--duration-base) var(--ease-standard),
|
||||
border-color var(--duration-base) var(--ease-standard);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 var(--space-4);
|
||||
@@ -43,12 +39,6 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-raised);
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
|
||||
.card .icons a:hover img {
|
||||
filter: none;
|
||||
transform: scale(1.1);
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
export type CommandId =
|
||||
| 'help'
|
||||
| 'projects'
|
||||
| 'servicesAi'
|
||||
| 'cv'
|
||||
| 'contact'
|
||||
| 'brew'
|
||||
| 'ignite'
|
||||
| 'rev'
|
||||
| 'clear'
|
||||
| 'close';
|
||||
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
|
||||
|
||||
export const COMMAND_IDS: readonly CommandId[] = [
|
||||
'help',
|
||||
'projects',
|
||||
'servicesAi',
|
||||
'cv',
|
||||
'contact',
|
||||
'brew',
|
||||
'ignite',
|
||||
'rev',
|
||||
'history',
|
||||
'clear',
|
||||
'close',
|
||||
'brew',
|
||||
'rev',
|
||||
'navigate',
|
||||
];
|
||||
|
||||
@@ -11,8 +11,10 @@ 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;
|
||||
|
||||
const UNLIMITED_SCOPE = /\balles\b|\banything\b|any problem|jedes Problem|end-to-end für alles/i;
|
||||
|
||||
function allMetrics(site: SiteContent): readonly MetricCopy[] {
|
||||
return [...site.home.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
|
||||
return [...site.pitch.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
|
||||
}
|
||||
|
||||
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
|
||||
@@ -41,7 +43,7 @@ describe('content claims and attribution', () => {
|
||||
/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.pitch.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);
|
||||
@@ -83,4 +85,53 @@ describe('content claims and attribution', () => {
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Home free of unlimited-scope promises', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const home = SITE_CONTENT_DATA[locale].home;
|
||||
const text = [
|
||||
home.title,
|
||||
home.description,
|
||||
home.hero.headline,
|
||||
home.hero.proof ?? '',
|
||||
home.hero.playfulLine ?? '',
|
||||
...(home.hero.body ?? []),
|
||||
...home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||
...home.serviceAreas.flatMap((area) => [area.title, area.body]),
|
||||
...home.process.flatMap((step) => [step.title, step.body]),
|
||||
home.proofNote,
|
||||
...home.ctas.map((cta) => cta.label),
|
||||
].join('\n');
|
||||
|
||||
expect(UNLIMITED_SCOPE.test(text), `${locale} home contains unlimited-scope wording`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps pitch timeline roles free of the period date', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const entry of SITE_CONTENT_DATA[locale].pitch.timeline) {
|
||||
expect(entry.role, `${locale}.${entry.id}`).not.toMatch(/\d{2}\/\d{4}/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Pitch and Home free of editorial omission commentary', () => {
|
||||
const editorial =
|
||||
/not listed on this site|auf dieser Seite zu listen|This page shows the public selection|Diese Seite zeigt die öffentliche Auswahl|What this page covers|Was diese Seite abdeckt/i;
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
const text = [
|
||||
...site.pitch.profile,
|
||||
...site.pitch.timeline.map((entry) => entry.body),
|
||||
...site.pitch.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||
...site.home.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
|
||||
...site.home.serviceAreas.map((area) => area.body),
|
||||
].join('\n');
|
||||
|
||||
expect(editorial.test(text), locale).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ describe('content completeness', () => {
|
||||
const site = SITE_CONTENT_DATA[locale];
|
||||
|
||||
expect(site.pages.home).toBe(site.home);
|
||||
expect(site.pages.pitch).toBe(site.pitch);
|
||||
expect(site.pages.services).toBe(site.servicesOverview);
|
||||
expect(site.pages.contact).toBe(site.contact);
|
||||
expect(site.pages.projects).toBe(site.projects);
|
||||
expect(site.pages.stack).toBe(site.stack);
|
||||
|
||||
@@ -52,7 +52,16 @@ describe('content exclusions', () => {
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const paths = ['/', '/en', '/projekte', '/en/projects', '/ueber-mich', '/en/about'];
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/pitch',
|
||||
'/en/pitch',
|
||||
'/projekte',
|
||||
'/en/projects',
|
||||
'/ueber-mich',
|
||||
'/en/about',
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
await harness.navigateByUrl(path);
|
||||
|
||||
@@ -35,9 +35,16 @@ export interface PageCopy {
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
}
|
||||
|
||||
export type CaseStudyId = 'innofocus' | 'roesterei' | 'myspa' | 'hdi';
|
||||
export type CaseStudyId = 'innofocus' | 'roesterei' | 'bannier' | 'elbwaerme' | 'hdi' | 'devdays';
|
||||
|
||||
export const CASE_STUDY_IDS: readonly CaseStudyId[] = ['innofocus', 'roesterei', 'myspa', 'hdi'];
|
||||
export const CASE_STUDY_IDS: readonly CaseStudyId[] = [
|
||||
'innofocus',
|
||||
'roesterei',
|
||||
'bannier',
|
||||
'elbwaerme',
|
||||
'hdi',
|
||||
'devdays',
|
||||
];
|
||||
|
||||
export interface MetricCopy {
|
||||
readonly id: string;
|
||||
@@ -70,6 +77,9 @@ export interface CaseStudyCopy {
|
||||
readonly tags: readonly string[];
|
||||
readonly linkLabel: string;
|
||||
readonly transparencyNote?: string;
|
||||
readonly teaser: string;
|
||||
readonly externalUrl?: string;
|
||||
readonly externalLabel?: string;
|
||||
}
|
||||
|
||||
export type OfferingStatus = 'delivered' | 'offer';
|
||||
@@ -82,42 +92,85 @@ export interface OfferingCopy {
|
||||
readonly referenceCaseId?: CaseStudyId;
|
||||
}
|
||||
|
||||
export interface ServiceSequenceCopy {
|
||||
export interface ServiceEvidenceCopy {
|
||||
readonly id: string;
|
||||
readonly heading: string;
|
||||
readonly note: string;
|
||||
readonly caseIds: readonly CaseStudyId[];
|
||||
}
|
||||
|
||||
export type ServiceSectionId =
|
||||
'infrastructure-network' | 'platform-operations' | 'software-data' | 'ai-workflows';
|
||||
|
||||
export const SERVICE_SECTION_IDS: readonly ServiceSectionId[] = [
|
||||
'infrastructure-network',
|
||||
'platform-operations',
|
||||
'software-data',
|
||||
'ai-workflows',
|
||||
];
|
||||
|
||||
export interface ServiceSectionCopy {
|
||||
readonly id: ServiceSectionId;
|
||||
readonly title: string;
|
||||
readonly lead: string;
|
||||
readonly situation: SectionCopy;
|
||||
readonly diagnosis: SectionCopy;
|
||||
readonly implementation: SectionCopy;
|
||||
readonly operation: SectionCopy;
|
||||
readonly referenceCaseId: CaseStudyId;
|
||||
readonly referenceHeadline: string;
|
||||
readonly referenceNote: string;
|
||||
readonly offerings: readonly OfferingCopy[];
|
||||
readonly evidence: ServiceEvidenceCopy;
|
||||
readonly inquiry: SectionCopy;
|
||||
}
|
||||
|
||||
export interface ServicePageCopy extends PageCopy {
|
||||
readonly sequence: ServiceSequenceCopy;
|
||||
readonly offerings: readonly OfferingCopy[];
|
||||
export interface ServicesPageCopy extends PageCopy {
|
||||
readonly serviceSections: readonly ServiceSectionCopy[];
|
||||
readonly sectionsHeading: string;
|
||||
readonly deliveredOfferingsHeading: string;
|
||||
readonly offerOfferingsHeading: string;
|
||||
readonly offeringCaseBackedLabel: string;
|
||||
readonly evidenceHeading: string;
|
||||
readonly inquiryHeading: string;
|
||||
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
||||
}
|
||||
|
||||
export interface AudienceEntryCopy {
|
||||
readonly id: 'recruiters' | 'companies';
|
||||
readonly headline: string;
|
||||
export interface HomeServiceAreaCopy {
|
||||
readonly id: ServiceSectionId;
|
||||
readonly title: string;
|
||||
readonly body: string;
|
||||
readonly bullets: readonly string[];
|
||||
readonly ctas: readonly CtaCopy[];
|
||||
readonly routeId: RouteId;
|
||||
readonly fragment: ServiceSectionId;
|
||||
}
|
||||
|
||||
export interface HomePageCopy extends PageCopy {
|
||||
readonly serviceAreas: readonly HomeServiceAreaCopy[];
|
||||
readonly serviceAreasHeading: string;
|
||||
readonly process: readonly ProcessStepCopy[];
|
||||
readonly processHeading: string;
|
||||
readonly proofCaseId: CaseStudyId;
|
||||
readonly proofHeading: string;
|
||||
readonly proofNote: string;
|
||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||
}
|
||||
|
||||
export interface PitchTimelineEntryCopy {
|
||||
readonly id: string;
|
||||
readonly period: string;
|
||||
readonly role: string;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface PitchPageCopy extends PageCopy {
|
||||
readonly profile: readonly string[];
|
||||
readonly metrics: readonly MetricCopy[];
|
||||
readonly audiences: readonly AudienceEntryCopy[];
|
||||
readonly timeline: readonly PitchTimelineEntryCopy[];
|
||||
readonly coreStack: readonly StackGroupCopy[];
|
||||
readonly featuredCaseIds: readonly CaseStudyId[];
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
};
|
||||
readonly timelineHeading: string;
|
||||
readonly stackHeading: string;
|
||||
}
|
||||
|
||||
export interface ServicesOverviewPageCopy extends PageCopy {
|
||||
readonly systemsMap: { readonly heading: string; readonly intro: string };
|
||||
}
|
||||
|
||||
export type ContactFieldId =
|
||||
@@ -146,7 +199,6 @@ export interface ContactPageCopy extends PageCopy {
|
||||
readonly incompleteHint: string;
|
||||
readonly requiredMarkerLabel: string;
|
||||
readonly directEmailLabel: string;
|
||||
readonly calendarLabel: string;
|
||||
readonly noBackendNote: string;
|
||||
}
|
||||
|
||||
@@ -186,9 +238,6 @@ export interface ProjectsPageCopy extends PageCopy {
|
||||
readonly caseLabels: CaseStudyLabels;
|
||||
}
|
||||
|
||||
export type ServicePageId =
|
||||
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
|
||||
|
||||
export interface SiteSeoCopy {
|
||||
readonly jobTitle: string;
|
||||
readonly professionalServiceName: string;
|
||||
@@ -198,7 +247,8 @@ export interface SiteSeoCopy {
|
||||
export interface SiteContent {
|
||||
readonly pages: Record<RouteId, PageCopy>;
|
||||
readonly home: HomePageCopy;
|
||||
readonly services: Record<ServicePageId, ServicePageCopy>;
|
||||
readonly pitch: PitchPageCopy;
|
||||
readonly services: ServicesPageCopy;
|
||||
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
|
||||
readonly contact: ContactPageCopy;
|
||||
readonly projects: ProjectsPageCopy;
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
type HomePageCopy,
|
||||
type LegalPageCopy,
|
||||
type PageCopy,
|
||||
type PitchPageCopy,
|
||||
type ProjectsPageCopy,
|
||||
type ServicePageCopy,
|
||||
type ServicePageId,
|
||||
type ServicesPageCopy,
|
||||
type StackPageCopy,
|
||||
} from './content.contracts';
|
||||
import { SITE_CONTENT } from './content.token';
|
||||
@@ -29,8 +29,12 @@ export class ContentService {
|
||||
return computed(() => this.content[this.localeService.locale()].home);
|
||||
}
|
||||
|
||||
service(id: ServicePageId): Signal<ServicePageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].services[id]);
|
||||
pitch(): Signal<PitchPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].pitch);
|
||||
}
|
||||
|
||||
services(): Signal<ServicesPageCopy> {
|
||||
return computed(() => this.content[this.localeService.locale()].services);
|
||||
}
|
||||
|
||||
caseStudy(id: CaseStudyId): Signal<CaseStudyCopy> {
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { type CaseStudyCopy } from '../content.contracts';
|
||||
import { type CaseStudyCopy, type CaseStudyId } from '../content.contracts';
|
||||
|
||||
export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
|
||||
export const CASES_DE: Record<CaseStudyId, 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',
|
||||
headline: 'Normalisierte ERP-Datenmigration im Microsoft SQL Server',
|
||||
summary:
|
||||
'Ersatz des bestehenden ERP durch ein neues, normalisiertes System. Die Migration aller Altdaten läuft direkt in Microsoft SQL Server.',
|
||||
'Das bestehende ERP wird durch ein neues, normalisiertes System ersetzt. Die Migration sämtlicher Altdaten läuft direkt im Microsoft SQL Server und liegt bei mir.',
|
||||
responsibility:
|
||||
'Alleinverantwortung: das Migrationsteam besteht aus einer Person. Als einziger Freiberufler steht er im direkten Kontakt mit dem Endkunden.',
|
||||
'Alleinverantwortung für die Migration: das Migrationsteam besteht aus mir. Als einziger Freiberufler stehe ich im direkten Austausch 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.',
|
||||
'Das bestehende ERP wird durch ein neues, normalisiertes System abgelöst; sämtliche Altdaten wandern direkt im Microsoft SQL Server mit. 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. Zuvor lag die Migration bei einem anderen Dienstleister und wurde von mir ü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.',
|
||||
'Umfangreiche Stored Procedures in T-SQL für Transformation, Normalisierung und Bereinigung der Altbestände.',
|
||||
'Punktuelle Mitarbeit an der Anwendung selbst mit C#, .NET und Angular.',
|
||||
'Authentifizierung und Autorisierung über Keycloak mit OAuth 2 und OIDC.',
|
||||
'Betrieb der Anwendung auf Kubernetes, GitOps-Deployments über Argo CD.',
|
||||
'Versionierung mit Git, Pipelines und Projektsteuerung über 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.',
|
||||
'Ein Migrationslauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
|
||||
'Die Zielstruktur ist normalisiert und für die prüfende Stelle dokumentiert.',
|
||||
],
|
||||
metrics: [
|
||||
@@ -45,8 +46,10 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'Argo CD',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL'],
|
||||
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL', 'Kubernetes'],
|
||||
linkLabel: 'Fall lesen: Innofocus / reifen.com',
|
||||
teaser:
|
||||
'ERP-Ablösung mit vollständiger Datenmigration im SQL Server: ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden.',
|
||||
},
|
||||
roesterei: {
|
||||
id: 'roesterei',
|
||||
@@ -54,22 +57,23 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
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',
|
||||
headline: 'Vom Netz bis zum Shop: die gesamte Technik in einer Hand',
|
||||
summary:
|
||||
'Ursprünglich sollte ein neuer Shop entstehen. Der Auftrag ist weit darüber hinausgewachsen und umfasst die gesamte Technik des Unternehmens.',
|
||||
'Aus dem Auftrag für einen neuen Onlineshop wurde die Verantwortung für die gesamte Technik des Unternehmens — Netz, Server, Mitarbeitergeräte, Microsoft 365, Shop und Marketing.',
|
||||
responsibility:
|
||||
'Vollständige Verantwortung für die Technik: Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, der neue Onlineshop und neues Marketing.',
|
||||
'Die gesamte Technik liegt bei mir: Netzwerk- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, der neue Onlineshop und das neue 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.',
|
||||
'Zuerst sollte nur ein neuer Onlineshop entstehen. Durch sehr dynamische Umstände wuchs der Gegenstand weit darüber hinaus. Anforderungen kommen direkt von nicht-technischen Beteiligten; die einheitliche Markenführung entsteht im Austausch mit einer Designerin.',
|
||||
approach: [
|
||||
'Zeitgemäße und kostengünstige Infrastruktur statt unnötiger Komplexität.',
|
||||
'Zeitgemäße und kosteneffektive Infrastruktur statt unnötiger Komplexität: Netz- und Serverstrukturen, Virtualisierung mit Proxmox, Container und Kubernetes, Microsoft 365.',
|
||||
'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.',
|
||||
'Shop-Version 2: Liquid und Hydrogen unter Einbindung eigener Dienste.',
|
||||
'Interne Werkzeuge für geringere Kosten und mehr Autonomie, darunter ein Generator für Vektorgrafiken in Werbematerial.',
|
||||
'Zwei KI-Werkzeuge im täglichen Einsatz: eines kategorisiert Kundenbewertungen, eines beschleunigt die Verwaltung des neuen Shops — vergleichbar mit Shopify Sidekick und in dieselbe 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.',
|
||||
'Zwei KI-Werkzeuge laufen im internen Betrieb: die Kategorisierung von Bewertungen und der Shop-Assistent.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
@@ -80,7 +84,7 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'TypeScript',
|
||||
'SCSS',
|
||||
'Java Spring WebFlux',
|
||||
'Java OpenAI library',
|
||||
'Java OpenAI Library',
|
||||
'MariaDB',
|
||||
'Docker',
|
||||
'Kubernetes',
|
||||
@@ -88,52 +92,81 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'GitLab CE',
|
||||
'Microsoft 365',
|
||||
],
|
||||
tags: ['eCommerce', 'interne IT', 'Shopify', 'KI-Werkzeuge'],
|
||||
tags: ['eCommerce', 'interne IT', 'Infrastruktur', 'Shopify', 'KI-Werkzeuge'],
|
||||
linkLabel: 'Fall lesen: Rösterei Tangermünde',
|
||||
transparencyNote:
|
||||
'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
|
||||
transparencyNote: 'Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
|
||||
teaser:
|
||||
'Gesamte Technik seit 10/2023: Netz, Server, Microsoft 365, Onlineshop und zwei interne KI-Werkzeuge.',
|
||||
},
|
||||
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',
|
||||
bannier: {
|
||||
id: 'bannier',
|
||||
client: 'Fahrschule Bannier',
|
||||
role: 'IT-Betreuung und Betrieb',
|
||||
period: 'seit etwa 2018, laufend',
|
||||
domain: 'Fahrschule / interne IT',
|
||||
headline: 'Die gesamte IT einer Fahrschule, betreut seit etwa 2018',
|
||||
summary:
|
||||
'Ersatz der Altsoftware durch neue Individualsoftware. Das Backend verbindet Website, internes Admin-Werkzeug, Zahlungsterminal, Schließfächer, smarte Geräte und Touchpanels.',
|
||||
'Ich betreue die gesamte IT der Fahrschule: die Computer, den laufenden Support, den Betrieb der eingesetzten Fachsoftware und die Website. Die Fachsoftware stammt von einem anderen Anbieter — entwickelt habe ich sie nicht.',
|
||||
responsibility:
|
||||
'Leitung des Backend-Teams (4 Personen) und des DevOps-Teams (2 Personen) bis zum Start der Software. Direkter Kundenkontakt für Anforderungen.',
|
||||
'Verantwortlich für die gesamte IT: die Computer, der laufende Support, die Website und der Betrieb der vorhandenen Fahrschulsoftware. Wichtig zur Einordnung: Diese Fachsoftware ist ein Fremdprodukt. Ich betreue und betreibe sie — ich habe sie nicht entwickelt.',
|
||||
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.',
|
||||
'Die Fahrschule arbeitet mit einer spezialisierten Fahrschulsoftware eines externen Anbieters. Meine Aufgabe ist der Betrieb und die Betreuung dieser Software im Alltag — Einrichtung, Aktualisierung, Anbindung, Störungen —, ausdrücklich nicht ihre Entwicklung. Dazu kommen die Computer, das Netz, der laufende Support und die Website unter fahrschule-stendal.de.',
|
||||
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.',
|
||||
'Betrieb und Betreuung der vorhandenen Fahrschulsoftware: Einrichtung, Aktualisierungen, Datensicherung und Störungsbehebung. Die Entwicklung dieser Software liegt beim Hersteller, nicht bei mir.',
|
||||
'Betreuung der Computer und des Netzes, einschließlich Austausch und Nachrüstung.',
|
||||
'Laufender Support als fester Ansprechpartner, ohne Ticketkette dazwischen.',
|
||||
'Betrieb und Pflege der öffentlichen Website unter fahrschule-stendal.de.',
|
||||
],
|
||||
outcome: [
|
||||
'Das Backend verbindet die genannten Kanäle bis zum Softwarestart.',
|
||||
'Entwicklungs-, QS- und Produktivsysteme laufen im Docker-Cluster.',
|
||||
'Die IT läuft seit etwa 2018 durchgehend unter derselben Betreuung.',
|
||||
'Ein Ansprechpartner für Rechner, Netz, Fachsoftware und Website. Die Fachsoftware bleibt ein Fremdprodukt; ich betreibe sie, entwickelt wird sie beim Hersteller.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'C#',
|
||||
'.NET 8',
|
||||
'ASP.NET Core',
|
||||
'Entity Framework 8',
|
||||
'xUnit',
|
||||
'MySQL',
|
||||
'OAuth 2',
|
||||
'MQTT',
|
||||
'Hangfire',
|
||||
'Docker',
|
||||
'GitLab EE',
|
||||
'Arbeitsplatzrechner',
|
||||
'Netzwerk',
|
||||
'Datensicherung',
|
||||
'Fachsoftware eines Drittanbieters (betreut und betrieben, nicht entwickelt)',
|
||||
'Website-Betrieb',
|
||||
],
|
||||
tags: ['IoT', 'Backend', 'DevOps', 'Docker'],
|
||||
linkLabel: 'Fall lesen: Aracom IT Services / MySpa',
|
||||
tags: ['interne IT', 'Support', 'Betrieb', 'Website'],
|
||||
linkLabel: 'Fall lesen: Fahrschule Bannier',
|
||||
teaser:
|
||||
'Gesamte IT seit etwa 2018: Rechner, Netz, Support, Website. Die Fachsoftware betreibe und betreue ich — entwickelt habe ich sie nicht.',
|
||||
externalUrl: 'https://fahrschule-stendal.de',
|
||||
externalLabel: 'fahrschule-stendal.de öffnen',
|
||||
},
|
||||
elbwaerme: {
|
||||
id: 'elbwaerme',
|
||||
client: 'Elbwärme',
|
||||
role: 'Infrastruktur und IT-Betreuung',
|
||||
period: 'seit 11/2025, laufend',
|
||||
domain: 'Infrastruktur und interne IT',
|
||||
headline: 'Infrastruktur, Microsoft 365 und Website unter einer Verantwortung',
|
||||
summary:
|
||||
'Seit 11/2025 verantworte ich bei Elbwärme die gesamte Infrastruktur, Microsoft 365 und die Website. Ein junges Mandat, das mitwächst.',
|
||||
responsibility:
|
||||
'Verantwortlich für die gesamte Infrastruktur, für Microsoft 365 und für die Website unter elbwaerme.de.',
|
||||
situation:
|
||||
'Elbwärme braucht eine technische Grundlage, die zum Betrieb passt und mitwächst: eine zusammenhängende Infrastruktur, die Dienste für die tägliche Arbeit und einen öffentlichen Auftritt. Die Verantwortung dafür liegt seit 11/2025 bei mir; das Mandat ist damit bewusst als jung beschrieben.',
|
||||
approach: [
|
||||
'Die Infrastruktur als zusammenhängende Schicht verantworten statt als Sammlung einzelner Geräte.',
|
||||
'Einrichtung und Betrieb von Microsoft 365 mit nachvollziehbaren Konten und Rechten.',
|
||||
'Betrieb und Pflege der Website unter elbwaerme.de.',
|
||||
'Ein fester Ansprechpartner für technische Fragen im Alltag.',
|
||||
],
|
||||
outcome: [
|
||||
'Infrastruktur, Microsoft 365 und Website liegen in einer Hand.',
|
||||
'Die Zusammenarbeit läuft seit 11/2025 weiter. Aussagen über lange Zeiträume gibt es hier bewusst noch nicht.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: ['Microsoft 365', 'Netzwerk', 'Serverbetrieb', 'Website-Betrieb'],
|
||||
tags: ['Infrastruktur', 'Microsoft 365', 'Betreuung', 'Website'],
|
||||
linkLabel: 'Fall lesen: Elbwärme',
|
||||
teaser:
|
||||
'Seit 11/2025: gesamte Infrastruktur, Microsoft 365 und Website in einer Hand. Ein junges, laufendes Mandat.',
|
||||
externalUrl: 'https://elbwaerme.de',
|
||||
externalLabel: 'elbwaerme.de öffnen',
|
||||
},
|
||||
hdi: {
|
||||
id: 'hdi',
|
||||
@@ -143,27 +176,27 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
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.',
|
||||
'Software, die Simulationsfolgen automatisiert, um gezielte Risiken zu ermitteln, sie 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.',
|
||||
'Senior-Fullstack- und DevOps-Arbeit an der Exposure-Management-Software, einschließlich der Migration aller Umgebungen auf ein Kubernetes-Cluster 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.',
|
||||
'HDI Specialty braucht Software für das Exposure Management: Simulationsfolgen sollen automatisiert bestimmte Risiken ermitteln, stochastisch ausgewertet werden, Policen zentral sammeln und Auswertungen aller Policen für die BaFin liefern. Parallel dazu weichen Entwicklungs-, QS- und Produktivumgebung auf Azure App Services voneinander ab.',
|
||||
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.',
|
||||
'Single-Page-Anwendung mit Angular, TypeScript, HTML und SCSS/CSS, Ngrx Store für den Anwendungsstatus und Ngx Pipes für die Datenaufbereitung.',
|
||||
'Backend mit C# und .NET, REST-API und Entity Framework für den Datenbankzugriff.',
|
||||
'Testgetriebene Entwicklung mit Unit- und Integrationstests in xUnit.',
|
||||
'Administration von Azure SQL Server, Anmeldung und Berechtigungen über Okta mit OAuth 2.',
|
||||
'CI/CD-Pipelines und Projektsteuerung mit Azure DevOps, Arbeit nach Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Entwicklungs-, QS- und Produktivsysteme wurden von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS migriert.',
|
||||
'Entwicklungs-, QS- und Produktivumgebung wurden von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS migriert.',
|
||||
'Die Auswertungen für die BaFin entstehen aus dem zentral gesammelten Policenbestand.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'Ngrx',
|
||||
'Ngrx Store',
|
||||
'Ngx Pipes',
|
||||
'C#',
|
||||
'.NET',
|
||||
@@ -175,7 +208,56 @@ export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'Azure AKS',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure'],
|
||||
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure', 'Regulierung'],
|
||||
linkLabel: 'Fall lesen: HDI Specialty',
|
||||
teaser:
|
||||
'Exposure Management mit Angular und .NET; Dev-, QS- und Produktivumgebung zogen von Azure App Services nach Azure AKS um.',
|
||||
},
|
||||
devdays: {
|
||||
id: 'devdays',
|
||||
client: 'Magdeburger Dev Days',
|
||||
role: 'Fullstack-Entwickler',
|
||||
period: '08/2020 – 03/2022',
|
||||
domain: 'Veranstaltung und Web',
|
||||
headline: 'Besucherseite, Planungswerkzeuge und eine Streamingplattform auf Kubernetes',
|
||||
summary:
|
||||
'Fullstack-Arbeit am jährlichen Entwicklerevent: die öffentliche Besucherseite und die internen Planungswerkzeuge. Für 2021 kam eine Streamingplattform auf Basis von Kubernetes dazu, damit die Veranstaltung online stattfinden konnte.',
|
||||
responsibility:
|
||||
'Fullstack-Entwicklung an Besucherseite und internen Planungswerkzeugen. Der Aufbau der Streamingplattform für die Online-Ausgabe 2021 lag bei mir.',
|
||||
situation:
|
||||
'Die Magdeburger Dev Days sind ein jährliches Entwicklerevent. Das Projekt umfasst die Homepage für Besucher und die internen Planungswerkzeuge. 2021 konnte die Veranstaltung nicht vor Ort stattfinden und brauchte eine eigene Plattform zum Streamen.',
|
||||
approach: [
|
||||
'Frontend mit Angular, TypeScript, HTML und SCSS/CSS, Ngrx Store für den Anwendungsstatus, Ngx Pipes für die Datenaufbereitung und Angular Material für die Komponenten.',
|
||||
'Node.js zur Unterstützung der Entwicklungsumgebung.',
|
||||
'Backend mit C# und .NET Core, Datenhaltung in Microsoft SQL Server 2019 mit SQL und T-SQL.',
|
||||
'Streamingplattform für die Ausgabe 2021 auf Basis von Kubernetes.',
|
||||
'CI/CD-Pipelines für automatisierte Tests und Deployments, Arbeit nach Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Die Ausgabe 2021 lief online über die selbst gebaute Streamingplattform.',
|
||||
'Besucherseite und interne Planungswerkzeuge wurden gemeinsam entwickelt und gepflegt.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'HTML',
|
||||
'SCSS',
|
||||
'Ngrx Store',
|
||||
'Ngx Pipes',
|
||||
'Angular Material',
|
||||
'Node.js',
|
||||
'C#',
|
||||
'.NET Core',
|
||||
'SQL',
|
||||
'T-SQL',
|
||||
'Microsoft SQL Server 2019',
|
||||
'Kubernetes',
|
||||
'GitLab CI/CD',
|
||||
],
|
||||
tags: ['Veranstaltung', 'Fullstack', 'Kubernetes', 'Streaming'],
|
||||
linkLabel: 'Fall lesen: Magdeburger Dev Days',
|
||||
teaser:
|
||||
'Besucherseite und interne Planungswerkzeuge; für 2021 eine Streamingplattform auf Kubernetes, damit das Event online lief.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,103 +1,109 @@
|
||||
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.',
|
||||
'Server, Netze und Betrieb aus einer Hand: Du beschreibst, was Dein Unternehmen erreichen soll, ich übernehme den Weg bis zum Betrieb oder zur Übergabe.',
|
||||
hero: {
|
||||
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
||||
headline:
|
||||
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
|
||||
proof:
|
||||
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freelance seit 04/2023.',
|
||||
playfulLine: 'IT mit Drehmoment',
|
||||
'Bei der Fahrschule Bannier liegt die gesamte IT seit etwa 2018 bei mir. Bei Elbwärme verantworte ich seit 11/2025 Infrastruktur, Microsoft 365 und Website.',
|
||||
playfulLine: 'Kaffee, Kabel, Code',
|
||||
body: [
|
||||
'Server, Netze und der Betrieb darauf sind der Ausgangspunkt. Darüber liegen die Plattformdienste, die Produktsoftware mit ihrer Datenarbeit und, wo es sich rechnet, ein KI-Werkzeug im internen Ablauf.',
|
||||
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ich ordne ein, sage, was dazugehört und was nicht, und führe von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
|
||||
],
|
||||
},
|
||||
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: [
|
||||
serviceAreasHeading: 'Wobei ich helfen kann',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'ca. 7 Jahre',
|
||||
label: 'berufliche Erfahrung mit Webanwendungen',
|
||||
id: 'infrastructure-network',
|
||||
title: 'Server, Netz und Virtualisierung',
|
||||
body: 'Die Schicht darunter: Netzstrukturen, Server und Virtualisierung vor Ort, geplant auf Kosten und Wartbarkeit statt auf Katalogseiten.',
|
||||
routeId: 'services',
|
||||
fragment: 'infrastructure-network',
|
||||
},
|
||||
{
|
||||
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: 'platform-operations',
|
||||
title: 'Plattform und Betrieb',
|
||||
body: 'Microsoft 365, Container und Kubernetes, Pipelines und GitOps — und eine klare Antwort darauf, wer das danach betreibt.',
|
||||
routeId: 'services',
|
||||
fragment: 'platform-operations',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
|
||||
caseId: 'myspa',
|
||||
id: 'software-data',
|
||||
title: 'Software und Daten',
|
||||
body: 'Produktsoftware von der Fachlichkeit bis zur Oberfläche, dazu SQL-Datenarbeit und Migrationen, die prüfbar bleiben.',
|
||||
routeId: 'services',
|
||||
fragment: 'software-data',
|
||||
},
|
||||
{
|
||||
id: 'ai-workflows',
|
||||
title: 'KI-Abläufe',
|
||||
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine werden je Auftrag geschnitten und geprüft.',
|
||||
routeId: 'services',
|
||||
fragment: 'ai-workflows',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
processHeading: 'So läuft die Zusammenarbeit',
|
||||
process: [
|
||||
{
|
||||
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: 'describe',
|
||||
title: 'Lage in Deinen Worten',
|
||||
body: 'Du beschreibst das Ziel, den Engpass oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
id: 'diagnose',
|
||||
title: 'Diagnose',
|
||||
body: 'Ich ordne die Lage einem der vier Bereiche zu, nenne die Grenze und sage, was außerhalb liegt.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'Umsetzung',
|
||||
body: 'Ich setze um, was vereinbart ist — allein, oder gemeinsam mit einem Partner aus dem Netzwerk, wenn das die bessere Lösung ergibt.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Betrieb oder Übergabe',
|
||||
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen und benannte Zuständigkeiten, kein undokumentierter Zwischenstand.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'Wie die Schichten zusammenhängen',
|
||||
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
|
||||
},
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
|
||||
proofNote: 'Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
|
||||
featuredCaseIds: ['bannier', 'elbwaerme', 'hdi', 'innofocus'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
id: 'areas',
|
||||
headline: 'Vier Bereiche, eine Reihenfolge',
|
||||
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.',
|
||||
'Server und Netz tragen den Plattformbetrieb, darauf läuft die Produktsoftware mit ihrer Datenarbeit, und KI-Abläufe hängen an dieser Kette. Was nicht dazugehört, benenne ich im Briefing, statt es stillschweigend mitzuverkaufen.',
|
||||
'Diese Reihenfolge ist auch die Reihenfolge der Diagnose. Ein Fehler weiter unten wird weiter oben nur teurer, nicht kleiner.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
id: 'team',
|
||||
headline: 'Wer hier arbeitet',
|
||||
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.',
|
||||
'Ich bin Dein technischer Ansprechpartner, vom ersten Gespräch bis in den Betrieb, ohne Zwischenschicht.',
|
||||
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Kaufmännisches und Organisation; er ist ausdrücklich kein zweiter Techniker und kein zweiter Entwickler. Dazu kommt ein ausgesuchtes Netzwerk: Passt jemand anderes besser, vermittle ich offen weiter oder wir setzen gemeinsam um.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'proof',
|
||||
headline: 'Warum die Rösterei als Beleg steht',
|
||||
body: [
|
||||
'Die Rösterei Tangermünde zeigt die Kette vom Netz über Microsoft 365 bis zum Onlineshop und zu zwei internen KI-Werkzeugen.',
|
||||
'Die anderen Fälle tragen jeweils einen Bereich. Sie stehen mit Zeitraum und Umfang da, mehr nicht.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Leistungen', routeId: 'services' },
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
{ label: 'Projektbriefing starten', routeId: 'contact' },
|
||||
{ label: 'Leistungen ansehen', routeId: 'services' },
|
||||
{ label: 'Server und Netz', routeId: 'services', fragment: 'infrastructure-network' },
|
||||
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -11,30 +11,31 @@ 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.',
|
||||
'Sechs öffentliche Fälle: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty und die Magdeburger Dev Days. Der vollständige Lebenslauf liegt als Download bereit.',
|
||||
hero: {
|
||||
headline: 'Vier öffentliche Fälle, bewusst ausgewählt',
|
||||
headline: 'Sechs öffentliche Fälle, bewusst ausgewählt',
|
||||
proof:
|
||||
'Die Seite zeigt eine kuratierte Auswahl. Der vollständige Lebenslauf steht als PDF zum Download bereit.',
|
||||
'Drei laufende Mandate und drei abgeschlossene Projekte. Der vollständige Lebenslauf steht als PDF zum Download bereit.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'selection',
|
||||
headline: 'Warum nur diese vier',
|
||||
headline: 'Warum diese sechs',
|
||||
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.',
|
||||
'Öffentlich stehen Innofocus / reifen.com, die Rösterei Tangermünde, die Fahrschule Bannier, Elbwärme, HDI Specialty und die Magdeburger Dev Days. Zusammen decken sie die vier Leistungsbereiche ab: laufende Infrastruktur, Plattformbetrieb, Produktsoftware mit Datenarbeit und die beiden umgesetzten KI-Werkzeuge.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
'Jeder Fall nennt Auftrag, Rolle, Zeitraum, Lage, Vorgehen und Ergebnis. Zahlen stehen nur dort, wo das Projekt sie hergegeben hat.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -69,7 +70,7 @@ export const STACK_DE: StackPageCopy = {
|
||||
id: 'groups',
|
||||
headline: 'Welche Gruppen die Arbeit berührt',
|
||||
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.',
|
||||
'Produktarbeit in Java und Spring sowie in C# und .NET, mit Angular auf der Oberfläche. Datenarbeit in Microsoft 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. Netz, Server und Virtualisierung vor Ort, dazu Microsoft 365 im laufenden Betrieb.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -87,14 +88,14 @@ export const STACK_DE: StackPageCopy = {
|
||||
{ id: 'tools', title: 'Werkzeuge' },
|
||||
],
|
||||
evidenceNote:
|
||||
'In den öffentlichen Fällen sind unter anderem Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox und Microsoft 365 belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
|
||||
'In den sechs öffentlichen Fällen sind unter anderem Angular, TypeScript, SCSS, C#, .NET, Entity Framework, xUnit, Java mit Spring WebFlux, Microsoft SQL Server, T-SQL, MariaDB, Node.js, Docker, Kubernetes, Proxmox, Azure AKS, Azure SQL Server, Azure DevOps, GitLab CI/CD, Argo CD, Keycloak, Okta, OAuth 2, Microsoft 365 sowie Shopify mit Liquid und Hydrogen belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
|
||||
};
|
||||
|
||||
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.',
|
||||
'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur in Tangermünde. Persönlicher Ansprechpartner, Zweierteam mit Magdeburg, dazu ein ausgesuchtes Fachnetzwerk.',
|
||||
hero: {
|
||||
headline: 'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur',
|
||||
proof:
|
||||
@@ -117,10 +118,12 @@ export const ABOUT_DE: PageCopy = {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-and-language',
|
||||
headline: 'Kundenkontakt, Team und Sprachen',
|
||||
id: 'contact-and-team',
|
||||
headline: 'Ansprechpartner, Team und Netzwerk',
|
||||
body: [
|
||||
'Direkter Kundenkontakt und Teamverantwortung sind Teil der bisherigen Arbeit, nicht eine spätere Ausbaustufe.',
|
||||
'Ich bin der technische Ansprechpartner — vom ersten Anforderungsgespräch bis in den Produktivbetrieb. Direkter Kundenkontakt und Teamverantwortung gehören zur bisherigen Arbeit, nicht zu einer späteren Ausbaustufe.',
|
||||
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Papierkram, Kaufmännisches und Organisation; er ist kein zweiter Entwickler und kein zweiter Techniker.',
|
||||
'Für Fragen außerhalb meines Zuschnitts gibt es ein ausgesuchtes Netzwerk. Ich vermittle offen weiter oder setze gemeinsam mit einem Partner um, wenn das die bessere Lösung ergibt.',
|
||||
'Deutsch ist Muttersprache. Englisch ist verhandlungssicher.',
|
||||
],
|
||||
},
|
||||
@@ -128,12 +131,13 @@ export const ABOUT_DE: PageCopy = {
|
||||
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.',
|
||||
'Die Rösterei-Arbeit bleibt der persönliche Akzent: Rösten und Drehmoment statt Leerlauf. Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projekte', routeId: 'projects' },
|
||||
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
|
||||
{ label: 'Kontakt', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -160,7 +164,7 @@ export const CONTACT_DE: ContactPageCopy = {
|
||||
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.',
|
||||
'Wer das Briefing nicht nutzen möchte, schreibt direkt an info@antoniolede.de. Ich antworte selbst, es sitzt kein Vertrieb dazwischen.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -173,12 +177,14 @@ export const CONTACT_DE: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Art des Vorhabens',
|
||||
required: true,
|
||||
hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'software', label: 'Software' },
|
||||
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
|
||||
{ value: 'clusters', label: 'Cluster' },
|
||||
{ value: 'ai', label: 'KI-Integration' },
|
||||
{ value: 'unsure', label: 'Noch unklar' },
|
||||
{ value: 'infrastructure-network', label: 'Server, Netz und Virtualisierung' },
|
||||
{ value: 'platform-operations', label: 'Plattform und Betrieb' },
|
||||
{ value: 'software-data', label: 'Software und Daten' },
|
||||
{ value: 'ai-workflows', label: 'KI-Abläufe' },
|
||||
{ value: 'other', label: 'Sonstiges' },
|
||||
],
|
||||
},
|
||||
@@ -198,7 +204,6 @@ export const CONTACT_DE: ContactPageCopy = {
|
||||
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.',
|
||||
};
|
||||
@@ -358,7 +363,7 @@ export const NOT_FOUND_DE: PageCopy = {
|
||||
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.',
|
||||
'Die Adresse gehört zu keiner öffentlichen Route. Die Links unten führen zurück in die Seite.',
|
||||
},
|
||||
sections: [],
|
||||
ctas: [
|
||||
|
||||
135
src/app/core/content/de/pitch.ts
Normal file
135
src/app/core/content/de/pitch.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type PitchPageCopy } from '../content.contracts';
|
||||
|
||||
export const PITCH_DE: PitchPageCopy = {
|
||||
routeId: 'pitch',
|
||||
title: 'Profil | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack- und DevOps-Ingenieur in Tangermünde: Webanwendungen bauen und betreiben, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
||||
proof:
|
||||
'Bau und Betrieb von Webanwendungen, von der Anforderung bis in den Produktivbetrieb. Freiberuflich seit 04/2023.',
|
||||
playfulLine: 'IT mit Drehmoment',
|
||||
},
|
||||
profile: [
|
||||
'Bau und Betrieb von Webanwendungen als beruflicher Schwerpunkt, vom Datenmodell bis in den Produktivbetrieb.',
|
||||
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
||||
'Freiberuflich 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.',
|
||||
'Heute technischer Ansprechpartner in einem Zweierteam zwischen Tangermünde und Magdeburg; die kaufmännische Seite liegt beim Kollegen, die Technik bei mir. Dazu ein ausgesuchtes Fachnetzwerk.',
|
||||
],
|
||||
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',
|
||||
},
|
||||
],
|
||||
timelineHeading: 'Stationen',
|
||||
timeline: [
|
||||
{
|
||||
id: 'bannier',
|
||||
period: 'seit etwa 2018',
|
||||
role: 'IT-Betreuung und Betrieb bei der Fahrschule Bannier',
|
||||
body: 'Die gesamte IT einer Fahrschule, einschließlich Betreuung und Betrieb der eingesetzten Fachsoftware. Diese Software stammt vom Hersteller; entwickelt habe ich sie nicht.',
|
||||
},
|
||||
{
|
||||
id: 'devdays',
|
||||
period: '08/2020 – 03/2022',
|
||||
role: 'Fullstack-Entwickler, Magdeburger Dev Days',
|
||||
body: 'Besucherseite und interne Planungswerkzeuge eines jährlichen Entwicklerevents, dazu eine Streamingplattform auf Kubernetes für die Ausgabe 2021.',
|
||||
},
|
||||
{
|
||||
id: 'self-employed',
|
||||
period: 'seit 12/2021',
|
||||
role: 'unternehmerisch selbständig',
|
||||
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb, dazu Rechnungsstellung und Kundenbeziehungen.',
|
||||
},
|
||||
{
|
||||
id: 'freelance',
|
||||
period: 'seit 04/2023',
|
||||
role: 'freiberuflich',
|
||||
body: 'Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
|
||||
},
|
||||
{
|
||||
id: 'hdi',
|
||||
period: '05/2023 – 01/2024',
|
||||
role: 'Senior Fullstack / DevOps bei HDI Specialty',
|
||||
body: 'Fullstack- und DevOps-Verantwortung in einem regulierten Versicherungsumfeld, einschließlich des Umzugs aller Umgebungen auf Azure AKS.',
|
||||
},
|
||||
{
|
||||
id: 'roesterei',
|
||||
period: 'seit 10/2023',
|
||||
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
|
||||
body: 'Ein Ansprechpartner für die gesamte Betriebstechnik; die wirtschaftliche Beteiligung bleibt offengelegt.',
|
||||
},
|
||||
{
|
||||
id: 'innofocus',
|
||||
period: 'seit 09/2025',
|
||||
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
|
||||
body: 'Alleinverantwortung für die Migration, als einziger Freiberufler im direkten Austausch mit dem Endkunden.',
|
||||
},
|
||||
{
|
||||
id: 'elbwaerme',
|
||||
period: 'seit 11/2025',
|
||||
role: 'Infrastruktur und IT-Betreuung bei Elbwärme',
|
||||
body: 'Infrastruktur, Microsoft 365 und Website; die Verantwortung liegt seit 11/2025 bei mir.',
|
||||
},
|
||||
],
|
||||
stackHeading: 'Kernstack',
|
||||
coreStack: [
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java und Spring',
|
||||
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
|
||||
},
|
||||
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
|
||||
{
|
||||
id: 'angular',
|
||||
title: 'Angular und TypeScript',
|
||||
body: 'Angular-SPAs, RxJS und SCSS.',
|
||||
},
|
||||
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
|
||||
{
|
||||
id: 'containers',
|
||||
title: 'Docker und Kubernetes',
|
||||
body: 'Container und Cluster vor Ort und in der Cloud.',
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
title: 'Azure einschließlich AKS',
|
||||
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
|
||||
},
|
||||
{
|
||||
id: 'cicd',
|
||||
title: 'GitLab CI/CD und Azure DevOps',
|
||||
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
body: [
|
||||
'Ich arbeite als Fullstack- und DevOps-Ingenieur aus Tangermünde, freiberuflich seit 04/2023. Der Lebenslauf als PDF führt die Stationen vollständig.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
body: [
|
||||
'Die öffentlichen Fälle decken eCommerce und ERP, laufende Infrastruktur und Betrieb, Versicherung und eine Veranstaltungsplattform ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||
],
|
||||
};
|
||||
@@ -1,436 +1,416 @@
|
||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
||||
import { type ServicesPageCopy } from '../content.contracts';
|
||||
|
||||
export const SERVICES_OVERVIEW_DE: PageCopy = {
|
||||
export const SERVICES_DE: ServicesPageCopy = {
|
||||
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.',
|
||||
'Server, Netze und Virtualisierung, Plattformbetrieb, Produktsoftware mit Datenarbeit und KI-Abläufe — ein Ansprechpartner von der Lage bis zum Betrieb.',
|
||||
hero: {
|
||||
headline: 'Vier Leistungsbereiche, ein Ansprechpartner',
|
||||
headline: 'Vier Bereiche, 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',
|
||||
'Server, Netze und Virtualisierung, Plattformbetrieb mit Microsoft 365 und Kubernetes, Produktsoftware mit SQL-Datenarbeit und KI-Abläufe. Jeder Bereich läuft von der Lage über Diagnose und Umsetzung bis in den Betrieb.',
|
||||
playfulLine: 'Vier Gänge, ein Antriebsstrang',
|
||||
body: [
|
||||
'Du beschreibst, was im Unternehmen klemmt oder wohin es gehen soll. Ich ordne das einem der vier Bereiche zu, sage, was dazugehört, und benenne, was außerhalb liegt.',
|
||||
'Jeder Bereich hat denselben Aufbau: Lage, Diagnose, Umsetzung, Betrieb und Übergabe, danach der Umfang des Angebots, ein öffentlicher Beleg und der Weg zur Anfrage. Über die Sprungmarken kommst Du direkt in den Bereich, der zu Deiner Lage passt.',
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'areas',
|
||||
headline: 'Die vier Bereiche',
|
||||
id: 'approach',
|
||||
headline: 'Wie ich vorgehe',
|
||||
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.',
|
||||
'Ich fange bei der Schicht an, die trägt: Netz, Server, Virtualisierung. Darüber liegen die Plattformdienste, darauf die Produktsoftware mit ihrer Datenarbeit, und ganz oben hängen KI-Abläufe an dem, was darunter läuft.',
|
||||
'Diese Reihenfolge ist kein Verkaufsweg, sondern eine Diagnosereihenfolge. Ein Fehler weiter unten wird weiter oben nur teurer, nicht kleiner.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'who',
|
||||
headline: 'Für wen das gedacht ist',
|
||||
id: 'team',
|
||||
headline: 'Wer hier arbeitet',
|
||||
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.',
|
||||
'Ich bin Dein technischer Ansprechpartner. Vom ersten Gespräch über die Umsetzung bis in den Betrieb sprichst Du mit derselben Person, und diese Person hat die Entscheidungen auch getroffen.',
|
||||
'Der Kern sind zwei Personen, zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Papierkram, Kaufmännisches und Organisation. Er ist ausdrücklich kein zweiter Entwickler und kein zweiter Techniker — die Technik liegt bei mir.',
|
||||
'Dazu kommt ein ausgesuchtes Netzwerk aus Spezialistinnen und Spezialisten. Passt ein Vorhaben besser dorthin, sage ich das offen und vermittle weiter, oder wir setzen es gemeinsam um. Über die Antwort entscheidet die Eignung, nicht meine freie Zeit.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'engagement',
|
||||
headline: 'So läuft eine Zusammenarbeit',
|
||||
id: 'boundaries',
|
||||
headline: 'Was ich nicht mitverkaufe',
|
||||
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.',
|
||||
'Was außerhalb eines Bereichs liegt, steht im Briefing und nicht im Kleingedruckten. Wenn eine Änderung nicht nötig ist oder sich der Aufwand nicht lohnt, hörst Du das von mir, bevor jemand eine Rechnung schreibt.',
|
||||
'Umgesetzte Arbeit und Angebot sind unten in jedem Bereich getrennt ausgewiesen. Was als Angebot dasteht, wird je Auftrag geschnitten und geprüft — es steht nicht für Kundenprojekte, die es so noch nicht gibt.',
|
||||
],
|
||||
},
|
||||
],
|
||||
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: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
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: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
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: [],
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
{ label: 'Server und Netz', routeId: 'services', fragment: 'infrastructure-network' },
|
||||
{ label: 'Plattform und Betrieb', routeId: 'services', fragment: 'platform-operations' },
|
||||
{ label: 'Software und Daten', routeId: 'services', fragment: 'software-data' },
|
||||
{ label: 'KI-Abläufe', routeId: 'services', fragment: 'ai-workflows' },
|
||||
{ label: 'Projektbriefing starten', routeId: 'contact' },
|
||||
{ label: 'Öffentliche Projekte', routeId: 'projects' },
|
||||
],
|
||||
sectionsHeading: 'Die vier Bereiche',
|
||||
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
|
||||
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
|
||||
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
|
||||
evidenceHeading: 'Belege aus der öffentlichen Arbeit',
|
||||
inquiryHeading: 'Anfrage',
|
||||
systemsMap: {
|
||||
heading: 'Wie die vier Bereiche zusammenhängen',
|
||||
intro:
|
||||
'Server und Netz tragen den Plattformbetrieb, darauf läuft die Produktsoftware mit ihrer Datenarbeit, und die KI-Abläufe hängen an dieser Kette. Die Projektknoten zeigen, wo die öffentlichen Fälle ansetzen.',
|
||||
},
|
||||
serviceSections: [
|
||||
{
|
||||
id: 'infrastructure-network',
|
||||
title: 'Server, Netz und Virtualisierung',
|
||||
lead: 'Die Schicht, auf der der Rest aufsetzt: Netzstrukturen, Server und Virtualisierung vor Ort, geplant auf Kosten und Wartbarkeit.',
|
||||
situation: {
|
||||
id: 'infrastructure-network-situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Das Netz ist gewachsen, nicht geplant. Ein Server steht im Nebenraum, ein zweiter irgendwo im Rechenzentrum, und niemand kann sicher sagen, welcher Dienst auf welchem Blech läuft.',
|
||||
'Meist fällt das erst auf, wenn etwas ausfällt oder ein Umbau ansteht: ein neuer Standort, mehr Fläche, mehr Leute, ein Dienst, der plötzlich verlässlich erreichbar sein muss.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'infrastructure-network-diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Zuerst nehme ich auf, was tatsächlich läuft: Wege im Netz, Server und ihre Rollen, Virtualisierung, Abhängigkeiten und die Stellen, an denen ein einzelner Ausfall den Betrieb anhält.',
|
||||
'Daraus wird eine kurze, prüfbare Einschätzung: was bleiben kann, was ersetzt gehört und was für seinen Nutzen schlicht zu teuer ist. Wenn keine Änderung nötig ist, sage ich das auch.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'infrastructure-network-implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Netz- und Serverstrukturen werden so gebaut, dass sie zum Haus passen: klare Segmente, dokumentierte Wege, Virtualisierung dort, wo sie Hardware und Wartung spart — bei der Rösterei Tangermünde zum Beispiel mit Proxmox.',
|
||||
'On-Premise bleibt eine bewusste Entscheidung, keine Notlösung. Wo eigene Hardware günstiger und wartbarer ist als ein Mietdienst, wird sie eingesetzt. Wo es umgekehrt ist, sage ich das.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'infrastructure-network-operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Danach läuft die Struktur unter meiner Betreuung weiter oder geht dokumentiert an Deine IT über. Übergabe heißt: nachvollziehbare Strukturen und benannte Zuständigkeiten, keine undokumentierte Sonderlocke.',
|
||||
'Zum laufenden Betrieb gehören nachgeordnet auch die Arbeitsplatzrechner und Mitarbeitergeräte, die an dieser Infrastruktur hängen — Einrichtung, Austausch und Support im Alltag, sofern das mit beauftragt ist.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'infra-network-and-server-structures',
|
||||
title: 'Netz- und Serverstrukturen',
|
||||
body: 'Aufbau und Betreuung von Netzwerk- und Serverstrukturen für kleine und mittlere Betriebe, vom Segmentschnitt bis zur dokumentierten Verkabelung. Bei der Rösterei Tangermünde liegt diese Schicht seit 10/2023 bei mir.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-virtualisation-on-premise',
|
||||
title: 'Virtualisierung vor Ort',
|
||||
body: 'Mehrere Dienste auf eigener Hardware statt auf mehreren Blechen. Virtualisierung mit Proxmox macht Wartung, Umzug und Wiederanlauf planbar, ohne die Struktur aus dem Haus zu geben.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-whole-it-small-business',
|
||||
title: 'IT-Gesamtbetreuung für kleine Betriebe',
|
||||
body: 'Ein Ansprechpartner für die gesamte IT eines Betriebs: Netz, Server, laufender Support und der Betrieb der eingesetzten Fachsoftware. Bei der Fahrschule Bannier läuft das seit etwa 2018. Die dortige Fachsoftware betreue und betreibe ich; entwickelt habe ich sie nicht.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'bannier',
|
||||
},
|
||||
{
|
||||
id: 'infra-infrastructure-responsibility',
|
||||
title: 'Infrastruktur in einer Verantwortung',
|
||||
body: 'Infrastruktur, die Dienste für die tägliche Arbeit und die Zugänge dazu unter einer Verantwortung, statt drei getrennter Vereinbarungen. Bei Elbwärme liegt das seit 11/2025 bei mir.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'elbwaerme',
|
||||
},
|
||||
{
|
||||
id: 'infra-inventory-and-risk-review',
|
||||
title: 'Bestandsaufnahme und Ausfallanalyse',
|
||||
body: 'Eine geführte Aufnahme der bestehenden Infrastruktur mit schriftlicher Einschätzung, an welchen Stellen ein einzelner Ausfall den Betrieb anhalten würde. Umfang und Tiefe werden vor Beginn festgelegt.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'infrastructure-network-evidence',
|
||||
heading: 'Belege aus der öffentlichen Arbeit',
|
||||
note: 'Die Fahrschule Bannier begleite ich seit etwa 2018 mit der gesamten IT, dem laufenden Support und dem Betrieb der vorhandenen Fachsoftware. Bei Elbwärme liegt die Infrastruktur seit 11/2025 bei mir, bei der Rösterei Tangermünde seit 10/2023 einschließlich Netz-, Server- und Virtualisierungsschicht.',
|
||||
caseIds: ['bannier', 'elbwaerme', 'roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'infrastructure-network-inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine Infrastrukturanfrage hilft wenig Technik und viel Alltag: wie viele Leute damit arbeiten, wo Server und Netzwerktechnik heute stehen, was regelmäßig ausfällt oder aufhält, und ob ein Umzug oder Umbau ansteht.',
|
||||
'Das Briefing bleibt im Browser. Der Knopf öffnet nur eine vorausgefüllte Nachricht im lokalen E-Mail-Programm; an diesen Server wird nichts gesendet.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'platform-operations',
|
||||
title: 'Plattform und Betrieb',
|
||||
lead: 'Die Dienste und Umgebungen, auf denen der Alltag läuft: Microsoft 365, Container und Cluster, Pipelines — und die Frage, wer das danach betreibt.',
|
||||
situation: {
|
||||
id: 'platform-operations-situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Anwendungen laufen auf einzelnen Hosts oder in einem PaaS-Dienst. Entwicklungs-, Test- und Produktivumgebung weichen voneinander ab, und ein Release hängt an Handgriffen, die eine einzelne Person im Kopf hat.',
|
||||
'Auf der anderen Seite steht das Tagesgeschäft: Postfächer, Ablagen, Rechte, Konten für neue Kolleginnen und Kollegen. Auch das ist Plattformbetrieb, nur unter anderem Namen.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'platform-operations-diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Ich schaue, welche Umgebungen wirklich identisch sein müssen, welche Last ein Cluster tragen soll und ob der Betriebsaufwand sich lohnt. Nicht jeder Dienst gehört in ein Kubernetes-Cluster.',
|
||||
'Bei Microsoft 365 geht es um Konten, Rechte und Ablagen: wer kommt an was, was passiert beim Eintritt und beim Austritt, und welche Einstellung ist seit Jahren nie wieder angefasst worden.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'platform-operations-implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Containerisierung mit Docker, Betrieb auf Kubernetes vor Ort oder auf Azure AKS, Pipelines in Azure DevOps oder GitLab CI/CD, GitOps-Deployments über Argo CD, wenn der Auftrag das hergibt.',
|
||||
'Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS umgezogen. Microsoft 365 richte ich so ein, dass Konten und Rechte einem nachvollziehbaren Muster folgen statt gewachsenen Einzelfällen.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'platform-operations-operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Der Stand soll sich aus dem Repository erklären, nicht aus einem einzelnen Rechner. Wer nach mir hineinschaut, findet Pipelines, Manifeste und Zugänge dort, wo er sie vermutet.',
|
||||
'Zum Betrieb gehören nachgeordnet auch die Endgeräte, die auf diese Dienste zugreifen: Einrichtung, Konten, Zugriffsrechte und der Support im Alltag, sofern das mit beauftragt ist.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'platform-microsoft-365',
|
||||
title: 'Microsoft 365 einrichten und betreiben',
|
||||
body: 'Konten, Postfächer, Ablagen und Rechte nach einem Muster, das auch beim nächsten neuen Konto noch trägt. Bei der Rösterei Tangermünde und bei Elbwärme läuft Microsoft 365 unter meiner Verantwortung.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'platform-containers-and-kubernetes',
|
||||
title: 'Container und Kubernetes-Cluster',
|
||||
body: 'Anwendungen containerisieren und auf einem Cluster betreiben, vor Ort oder auf Azure AKS. Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services nach Azure AKS umgezogen.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'platform-pipelines-and-gitops',
|
||||
title: 'CI/CD-Pipelines und GitOps',
|
||||
body: 'Bauen, Testen und Ausrollen als wiederholbarer Vorgang: Pipelines in Azure DevOps oder GitLab CI/CD, Deployments über Argo CD. Bei Innofocus / reifen.com läuft die Anwendung auf diesem Weg auf Kubernetes.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'platform-aligning-environments',
|
||||
title: 'Umgebungen angleichen',
|
||||
body: 'Entwicklungs-, Test- und Produktivumgebung auf denselben Stand bringen, damit ein Fehler nicht erst in der Produktion sichtbar wird. Der Zuschnitt hängt an der vorhandenen Werkzeugkette und wird vorab festgelegt.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'platform-handover-to-your-team',
|
||||
title: 'Betriebsübergabe an Dein Team',
|
||||
body: 'Wenn der Betrieb intern weitergehen soll, wird er dokumentiert und begleitet übergeben: Zugänge, Abläufe, wiederkehrende Aufgaben, Ansprechwege. Der Umfang wird je Auftrag vereinbart.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'platform-operations-evidence',
|
||||
heading: 'Belege aus der öffentlichen Arbeit',
|
||||
note: 'Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS umgezogen, mit CI/CD über Azure DevOps. Bei der Rösterei Tangermünde laufen Microsoft 365, Docker, Kubernetes und GitLab CE unter derselben Verantwortung wie der Onlineshop. Bei Elbwärme gehört Microsoft 365 seit 11/2025 zur laufenden Betreuung.',
|
||||
caseIds: ['hdi', 'roesterei', 'elbwaerme'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'platform-operations-inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine Plattformanfrage sind drei Angaben nützlich: worauf die Anwendungen heute laufen, wie ein Release heute abläuft, und ob der Betrieb später intern oder bei mir liegen soll.',
|
||||
'Das Briefing bleibt im Browser und öffnet nur das lokale E-Mail-Programm. An diesen Server wird nichts übertragen.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'software-data',
|
||||
title: 'Software und Daten',
|
||||
lead: 'Produktsoftware von der Fachlichkeit über die Schnittstelle bis zur Oberfläche — und die Datenarbeit, die meistens der eigentliche Aufwand ist.',
|
||||
situation: {
|
||||
id: 'software-data-situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Eine Anwendung soll neu entstehen oder ein bestehendes System soll abgelöst werden. Sichtbar ist die Oberfläche; der Aufwand steckt in der Fachlichkeit und in den Altdaten.',
|
||||
'Häufig kommt beides zusammen: ein neues, sauber geschnittenes System auf der einen Seite, ein gewachsener Datenbestand auf der anderen, der vollständig und prüfbar mitkommen muss.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'software-data-diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Zuerst der fachliche Schnitt, dann das Datenmodell, dann die Schnittstellen. Domain-driven Design und Tests machen die Annahmen sichtbar, bevor Code in die Breite geht.',
|
||||
'Bei Migrationen lese ich zuerst den Altbestand, statt den Zielentwurf zu verteidigen: Welche Felder tragen wirklich Bedeutung, welche Dubletten gibt es, und was muss für eine prüfende Stelle dokumentiert sein?',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'software-data-implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Backend in Java mit Spring Boot oder in C# mit .NET, REST-Schnittstellen, Spring Data JPA oder Entity Framework, Angular und TypeScript auf der Oberfläche, testgetriebene Entwicklung mit JUnit oder xUnit.',
|
||||
'Datenarbeit in Microsoft SQL Server mit T-SQL: Stored Procedures für Transformation, Normalisierung und Bereinigung. Anmeldung und Berechtigungen über Keycloak oder Okta mit OAuth 2 und OIDC.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'software-data-operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Ausgeliefert wird ein Stand, der sich selbst erklärt: Tests, Migrationsskripte und Schnittstellen liegen im Repository, nicht in einem Kopf. Wo eine prüfende Stelle mitliest, entsteht die Dokumentation gleich mit.',
|
||||
'Danach betreue ich die Anwendung weiter oder übergebe sie an Dein Team — mit Zugängen, Abläufen und dem Wissen, das für den nächsten Ausbau nötig ist.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'software-fullstack-product-software',
|
||||
title: 'Fullstack-Produktsoftware',
|
||||
body: 'Neue Anwendungen von der Domäne bis zur Oberfläche: Angular im Frontend, C# und .NET oder Java und Spring im Backend, REST-Schnittstellen, testgetriebene Entwicklung. Bei HDI Specialty ist auf diesem Weg die Software für das Exposure Management entstanden.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'software-legacy-data-migration',
|
||||
title: 'Datenmigration und Normalisierung',
|
||||
body: 'Altbestände in eine neue, normalisierte Struktur überführen, prüfbar und wiederholbar — auch dann, wenn ein anderer Dienstleister damit angefangen hat. Bei Innofocus / reifen.com liegt die Migration im Microsoft SQL Server allein bei mir; ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-sql-and-tsql-data-work',
|
||||
title: 'SQL- und T-SQL-Datenarbeit',
|
||||
body: 'Stored Procedures, Transformation, Bereinigung und Auswertung direkt im Microsoft SQL Server — dort, wo die Daten liegen, statt über Umwege in der Anwendung.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-replacing-a-legacy-system',
|
||||
title: 'Bestandssystem ablösen',
|
||||
body: 'Ein gewachsenes System schrittweise durch ein neues ersetzen, ohne den laufenden Betrieb anzuhalten. Reihenfolge, Schnitt und Rückfallweg werden vor der ersten Zeile Code festgelegt.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'software-data-model-and-api-review',
|
||||
title: 'Datenmodell und Schnittstellen prüfen',
|
||||
body: 'Eine schriftliche Einschätzung zu Datenmodell, Schnittstellen und Testabdeckung einer bestehenden Anwendung, mit benannten Risiken und einer Reihenfolge. Der Umfang wird vorab festgelegt.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'software-data-evidence',
|
||||
heading: 'Belege aus der öffentlichen Arbeit',
|
||||
note: 'Bei Innofocus / reifen.com verantworte ich seit 09/2025 die vollständige Migration der Altdaten in ein neues, normalisiertes System; ein Migrationslauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität. Bei den Magdeburger Dev Days entstanden Besucherseite und interne Planungswerkzeuge als Fullstack-Arbeit. Bei HDI Specialty lag die Software für das Exposure Management einschließlich der BaFin-Auswertungen in meiner Verantwortung.',
|
||||
caseIds: ['innofocus', 'devdays', 'hdi'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'software-data-inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine Softwareanfrage: was das System können soll, welches System es ersetzt oder ergänzt, wo die Daten heute liegen und ob eine prüfende Stelle mitliest.',
|
||||
'Das Briefing bleibt im Browser und erzeugt nur eine lokale E-Mail. An diesen Server wird nichts gesendet.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ai-workflows',
|
||||
title: 'KI-Abläufe',
|
||||
lead: 'Zwei umgesetzte Werkzeuge und ein klar benannter Rest. Was KI hier leistet, hängt am konkreten Ablauf und wird je Auftrag geprüft.',
|
||||
situation: {
|
||||
id: 'ai-workflows-situation',
|
||||
headline: 'Lage',
|
||||
body: [
|
||||
'Ein interner Ablauf kostet Zeit, ohne dass dabei jemand etwas entscheidet: Bewertungen sortieren, immer gleiche Schritte in der Verwaltung, Texte in eine Form bringen.',
|
||||
'Gleichzeitig steht die Frage im Raum, ob überhaupt ein Modell nötig ist. Manchmal reicht eine Regel, eine Abfrage oder ein zusätzlicher Knopf an der richtigen Stelle.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'ai-workflows-diagnosis',
|
||||
headline: 'Diagnose',
|
||||
body: [
|
||||
'Zuerst der Ablauf, dann die Datengrenze, dann das Werkzeug. Ich schaue, welche Schritte wirklich wiederkehren, welche Daten das Haus nicht verlassen dürfen und an welcher Stelle eine Person die Entscheidung behalten muss.',
|
||||
'Erst danach steht fest, ob ein externes Modell, ein lokal betriebenes Modell oder gar kein Modell die passende Antwort ist. Das Ergebnis kann auch lauten, dass der Aufwand sich nicht lohnt.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'ai-workflows-implementation',
|
||||
headline: 'Umsetzung',
|
||||
body: [
|
||||
'Wo ein Auftrag es hergibt, entsteht ein schmales Werkzeug innerhalb der bestehenden Kette — nicht ein Chatfenster neben dem eigentlichen System. Bei der Rösterei Tangermünde sind auf diesem Weg zwei Werkzeuge entstanden, angebunden an die interne Werkzeugkette.',
|
||||
'Was darüber hinausgeht, wird je Auftrag geschnitten, begrenzt und geprüft. Ich weise es hier als Angebot aus, nicht als vorhandene Kundenerfahrung.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'ai-workflows-operation',
|
||||
headline: 'Betrieb und Übergabe',
|
||||
body: [
|
||||
'Ein KI-Werkzeug bleibt Teil der internen Werkzeugkette und wird wie jede andere Anwendung betrieben: versioniert, dokumentiert, abschaltbar. Wenn es nicht mehr trägt, kommt es weg.',
|
||||
'Zur Übergabe gehört, welches Modell angebunden ist, welche Daten es sieht, was protokolliert wird und an welcher Stelle ein Mensch freigibt.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'ai-review-categorisation',
|
||||
title: 'Kategorisierung von Kundenbewertungen',
|
||||
body: 'Ein KI-Werkzeug, das eingehende Kundenbewertungen kategorisiert. Umgesetzt für die Rösterei Tangermünde und dort in die interne Werkzeugkette eingebunden.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-shop-management-assistant',
|
||||
title: 'Assistent für die Shop-Verwaltung',
|
||||
body: 'Ein KI-Werkzeug für die schnelle Verwaltung des neuen Onlineshops, vergleichbar mit Shopify Sidekick und ebenfalls in die internen Werkzeuge integriert. Umgesetzt für die Rösterei Tangermünde.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-process-analysis-first',
|
||||
title: 'Prozessanalyse vor dem Modell',
|
||||
body: 'Gemeinsames Zuschneiden des Ablaufs, bevor überhaupt ein Modell ins Spiel kommt. Nutzen und Umfang werden am konkreten Auftrag geprüft und nicht aus anderen Projekten übertragen.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-external-or-local-models',
|
||||
title: 'Anbindung externer oder lokaler Modelle',
|
||||
body: 'Ein externes Modell über eine API anbinden oder prüfen, ob ein lokal betriebenes Modell für den Auftrag tragfähig ist. Beides ist eine Klärung am konkreten Fall, keine Darstellung allgemeiner Produktionserfahrung.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-retrieval-with-data-boundary',
|
||||
title: 'RAG mit Datengrenze',
|
||||
body: 'Wenn interne Texte ein Modell stützen sollen, wird RAG — der Abruf angereicherter Texte — samt Datengrenze am konkreten Bestand entworfen. Der Zuschnitt gilt nur für diesen Auftrag und wird mit der Fachseite geprüft.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-human-approval-and-multi-step',
|
||||
title: 'Freigabe durch Menschen und mehrstufige Abläufe',
|
||||
body: 'Ein Vorschlag, an welchen Stellen eine Person freigibt oder korrigiert und wie mehrere spezialisierte Schritte nacheinander laufen könnten. Beides wird am Auftrag skizziert und geprüft; es steht nicht für vorhandene Projekte dieser Art.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'ai-workflows-evidence',
|
||||
heading: 'Belege aus der öffentlichen Arbeit',
|
||||
note: 'Umgesetzt sind genau zwei KI-Werkzeuge, beide bei der Rösterei Tangermünde: die Kategorisierung von Kundenbewertungen und ein Assistent für die schnelle Shop-Verwaltung, vergleichbar mit Shopify Sidekick und in die internen Werkzeuge eingebunden. Beide laufen im internen Betrieb, nicht als Produkt. Was in diesem Bereich sonst aufgeführt ist, ist Angebot und wird je Auftrag geprüft.',
|
||||
caseIds: ['roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'ai-workflows-inquiry',
|
||||
headline: 'Anfrage',
|
||||
body: [
|
||||
'Für eine KI-Anfrage: welcher Ablauf entlastet werden soll, wie oft er vorkommt, welche Daten dabei anfallen und welche davon das Haus nicht verlassen dürfen.',
|
||||
'Das Briefing bleibt im Browser und öffnet nur das lokale E-Mail-Programm. An diesen Server wird nichts gesendet — was für ein Gespräch über Daten der ehrlichere Anfang ist.',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_DE } from './cases';
|
||||
import { HOME_DE } from './home';
|
||||
import { PITCH_DE } from './pitch';
|
||||
import {
|
||||
ABOUT_DE,
|
||||
CONTACT_DE,
|
||||
@@ -10,22 +11,12 @@ import {
|
||||
PROJECTS_DE,
|
||||
STACK_DE,
|
||||
} from './pages';
|
||||
import {
|
||||
SERVICES_AI_DE,
|
||||
SERVICES_CLUSTERS_DE,
|
||||
SERVICES_HARDWARE_DE,
|
||||
SERVICES_OVERVIEW_DE,
|
||||
SERVICES_SOFTWARE_DE,
|
||||
} from './services';
|
||||
import { SERVICES_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,
|
||||
},
|
||||
pitch: PITCH_DE,
|
||||
services: SERVICES_DE,
|
||||
cases: CASES_DE,
|
||||
contact: CONTACT_DE,
|
||||
projects: PROJECTS_DE,
|
||||
@@ -36,17 +27,14 @@ export const SITE_CONTENT_DE: SiteContent = {
|
||||
},
|
||||
seo: {
|
||||
jobTitle: 'Fullstack- und DevOps-Ingenieur',
|
||||
professionalServiceName: 'Software- und DevOps-Leistungen',
|
||||
professionalServiceName: 'IT-Infrastruktur, Plattformbetrieb und Softwareentwicklung',
|
||||
professionalServiceDescription:
|
||||
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
|
||||
'Server und Netze, Plattformbetrieb, Produktsoftware mit Datenarbeit und KI-Abläufe — mit einem technischen Ansprechpartner vom ersten Briefing bis in den Betrieb.',
|
||||
},
|
||||
pages: {
|
||||
home: HOME_DE,
|
||||
services: SERVICES_OVERVIEW_DE,
|
||||
servicesSoftware: SERVICES_SOFTWARE_DE,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
|
||||
servicesClusters: SERVICES_CLUSTERS_DE,
|
||||
servicesAi: SERVICES_AI_DE,
|
||||
pitch: PITCH_DE,
|
||||
services: SERVICES_DE,
|
||||
projects: PROJECTS_DE,
|
||||
stack: STACK_DE,
|
||||
about: ABOUT_DE,
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { type CaseStudyCopy } from '../content.contracts';
|
||||
import { type CaseStudyCopy, type CaseStudyId } from '../content.contracts';
|
||||
|
||||
export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
|
||||
export const CASES_EN: Record<CaseStudyId, CaseStudyCopy> = {
|
||||
innofocus: {
|
||||
id: 'innofocus',
|
||||
client: 'Innofocus / reifen.com',
|
||||
role: 'Senior backend and database engineer',
|
||||
period: 'since 09/2025, ongoing',
|
||||
period: 'since September 2025, ongoing',
|
||||
domain: 'eCommerce / ERP',
|
||||
headline: 'Normalised ERP data migration inside Microsoft SQL Server',
|
||||
headline: 'A grown ERP data store moved into a normalised system',
|
||||
summary:
|
||||
'Replacement of the existing ERP with a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server.',
|
||||
'The existing ERP is being replaced by 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.',
|
||||
'Sole responsibility for the migration — the migration team is one person. As the only freelancer on it, I deal with the end customer directly.',
|
||||
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.',
|
||||
'The legacy structure is not normalised and the target structure is, so the migration is largely data cleansing, normalisation and documentation for an auditor. Another service provider had run the migration before me; I took it over and continued from what was there.',
|
||||
approach: [
|
||||
'Extensive T-SQL stored procedures for transformation, normalisation and cleansing.',
|
||||
'Extensive T-SQL stored procedures for the transformation, normalisation and cleansing of the legacy data.',
|
||||
'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.',
|
||||
'Authentication and authorisation through Keycloak with OAuth 2 and OIDC.',
|
||||
'The application runs on Kubernetes, with GitOps deployments through Argo CD.',
|
||||
'Version control with Git, pipelines and project management 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 migration run takes about 90 minutes instead of around eight hours, at a larger feature scope and higher data quality.',
|
||||
'The target structure is normalised and documented for the auditor.',
|
||||
],
|
||||
metrics: [
|
||||
@@ -29,7 +30,7 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
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',
|
||||
note: 'at a larger feature scope and higher data quality',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
@@ -45,31 +46,34 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'Argo CD',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['eCommerce', 'ERP', 'data migration', 'SQL'],
|
||||
tags: ['eCommerce', 'ERP', 'data migration', 'SQL', 'Kubernetes'],
|
||||
linkLabel: 'Read the case: Innofocus / reifen.com',
|
||||
teaser:
|
||||
'Sole responsibility for an ERP data migration in SQL Server. The run went from around eight hours to about 90 minutes.',
|
||||
},
|
||||
roesterei: {
|
||||
id: 'roesterei',
|
||||
client: 'Rösterei Tangermünde',
|
||||
role: 'Full technical responsibility for the company',
|
||||
period: 'since 10/2023, ongoing',
|
||||
role: 'Full technical responsibility',
|
||||
period: 'since October 2023, ongoing',
|
||||
domain: 'eCommerce and internal IT',
|
||||
headline: 'Shop-floor IT, storefront and internal tools under one roof',
|
||||
headline: 'Network, servers, shop and internal tools under one responsibility',
|
||||
summary:
|
||||
'The original goal was a new shop. The scope grew far beyond that and now covers the company’s technology as a whole.',
|
||||
"The brief started as a new online shop. It grew into responsibility for 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.',
|
||||
'The whole of the technology sits with me: network structures, server structures, employee devices, management SaaS such as Microsoft 365, the new online shop and the 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.',
|
||||
'The brief was a new online shop. Very dynamic circumstances pushed the scope well past that. Requirements come straight from people who do not work in technology, and the brand side is worked out together with a designer, so every technical decision has to be explainable in plain language.',
|
||||
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.',
|
||||
'Contemporary, cost-effective infrastructure instead of added complexity: network and server structures, virtualisation on Proxmox, Docker and Kubernetes, GitLab CE, Microsoft 365.',
|
||||
'Shop version 1 on Shopify, with a custom theme and custom components in Liquid.',
|
||||
'Shop version 2 with Liquid and Hydrogen, with our own services integrated into it.',
|
||||
'Internal tools for lower cost and more autonomy, among them a generator for the vector graphics used in advertising material.',
|
||||
'Two AI tools in daily use: one categorises customer reviews, one speeds up managing the new shop — comparable to Shopify Sidekick and integrated into the same 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.',
|
||||
"The company's technology has one owner, from the network through to the storefront.",
|
||||
'Two AI tools run in internal use: the review categorisation and the shop assistant.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
@@ -80,7 +84,7 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'TypeScript',
|
||||
'SCSS',
|
||||
'Java Spring WebFlux',
|
||||
'Java OpenAI library',
|
||||
'Java OpenAI Library',
|
||||
'MariaDB',
|
||||
'Docker',
|
||||
'Kubernetes',
|
||||
@@ -88,81 +92,111 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'GitLab CE',
|
||||
'Microsoft 365',
|
||||
],
|
||||
tags: ['eCommerce', 'internal IT', 'Shopify', 'AI tools'],
|
||||
tags: ['eCommerce', 'internal IT', 'infrastructure', 'Shopify', 'AI tools'],
|
||||
linkLabel: 'Read the case: Rösterei Tangermünde',
|
||||
transparencyNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
|
||||
transparencyNote: 'I hold an economic participation in Rösterei Tangermünde.',
|
||||
teaser:
|
||||
'Full technical responsibility for one company: network, servers, Microsoft 365, the shop, and two AI tools in internal use.',
|
||||
},
|
||||
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',
|
||||
bannier: {
|
||||
id: 'bannier',
|
||||
client: 'Fahrschule Bannier',
|
||||
role: 'IT support and operation',
|
||||
period: 'since roughly 2018, ongoing',
|
||||
domain: 'Driving school / internal IT',
|
||||
headline: 'The whole IT of a driving school, kept running year after year',
|
||||
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.',
|
||||
'One contact for the entire IT of a driving school: the computers, the network, the website, and the support and operation of the specialist software the school already uses.',
|
||||
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.',
|
||||
'I support and operate the existing driving-school software. I did not develop it — it is a third-party product, and my responsibility is that it runs, not what is inside it. Alongside that I look after the computers, the rest of the IT and the website.',
|
||||
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.',
|
||||
'A driving school runs on specialist software it bought in, on the computers in its offices, and on a website its customers find it through. The software was not written by me and never has been; it comes from its vendor. What was missing was somebody to keep the whole thing working, and that has been my part since roughly 2018.',
|
||||
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.',
|
||||
"Support and operation of the existing, third-party driving-school software: installation, updates, day-to-day problems, and dealing with the vendor when the issue belongs to them. Developing it is the vendor's job, not mine.",
|
||||
'The computers and the rest of the on-site IT kept in working order, including replacement and upgrades.',
|
||||
'Day-to-day support from a named contact, without a ticket chain in between.',
|
||||
'The website of the driving school, published at fahrschule-stendal.de, operated and maintained.',
|
||||
],
|
||||
outcome: [
|
||||
'The backend connects the channels listed above through to the software launch.',
|
||||
'Development, QA and production systems run on the Docker cluster.',
|
||||
'The school has had one IT contact since roughly 2018, without an in-house IT department.',
|
||||
"The specialist software stays operable in daily use; developing that software remains the vendor's job, not mine.",
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'C#',
|
||||
'.NET 8',
|
||||
'ASP.NET Core',
|
||||
'Entity Framework 8',
|
||||
'xUnit',
|
||||
'MySQL',
|
||||
'OAuth 2',
|
||||
'MQTT',
|
||||
'Hangfire',
|
||||
'Docker',
|
||||
'GitLab EE',
|
||||
'Workstations and on-site IT',
|
||||
'Company network',
|
||||
'Backups',
|
||||
'Third-party driving-school software (operated and supported, not developed)',
|
||||
'Website operation',
|
||||
],
|
||||
tags: ['IoT', 'backend', 'DevOps', 'Docker'],
|
||||
linkLabel: 'Read the case: Aracom IT Services / MySpa',
|
||||
tags: ['internal IT', 'support', 'operation', 'website'],
|
||||
linkLabel: 'Read the case: Fahrschule Bannier',
|
||||
teaser:
|
||||
'The whole IT of a driving school since roughly 2018. I support and operate its specialist software — I did not build it.',
|
||||
externalUrl: 'https://fahrschule-stendal.de',
|
||||
externalLabel: 'Visit fahrschule-stendal.de',
|
||||
},
|
||||
elbwaerme: {
|
||||
id: 'elbwaerme',
|
||||
client: 'Elbwärme',
|
||||
role: 'Infrastructure and IT support',
|
||||
period: 'since November 2025, ongoing',
|
||||
domain: 'Infrastructure and internal IT',
|
||||
headline: 'Infrastructure, Microsoft 365 and the website in one hand',
|
||||
summary:
|
||||
"Responsibility for the company's infrastructure, its Microsoft 365 environment and its website, since November 2025.",
|
||||
responsibility:
|
||||
'The infrastructure, Microsoft 365 and the website sit with me. The engagement started in November 2025, so this is a young one; it is listed for what it covers, not for how long it has run.',
|
||||
situation:
|
||||
'Elbwärme wanted one technical contact for the systems the company works with, rather than three separate arrangements. The scope is the infrastructure, the Microsoft 365 environment and the public website at elbwaerme.de, and it has been mine since November 2025.',
|
||||
approach: [
|
||||
'The infrastructure carried as one connected layer rather than a collection of separate devices.',
|
||||
'Microsoft 365 as the managed layer for the accounts and the everyday services.',
|
||||
'The public website at elbwaerme.de, operated and maintained.',
|
||||
'One contact for all three, instead of a separate arrangement for each.',
|
||||
],
|
||||
outcome: [
|
||||
'Infrastructure, Microsoft 365 and the website are handled by the same person.',
|
||||
'The engagement has run since November 2025 and is ongoing. Nothing longer-standing is claimed for it.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: ['Microsoft 365', 'Company network', 'Server operation', 'Website operation'],
|
||||
tags: ['infrastructure', 'Microsoft 365', 'operation', 'website'],
|
||||
linkLabel: 'Read the case: Elbwärme',
|
||||
teaser:
|
||||
'Infrastructure, Microsoft 365 and the website for one company, since November 2025 and ongoing.',
|
||||
externalUrl: 'https://elbwaerme.de',
|
||||
externalLabel: 'Visit elbwaerme.de',
|
||||
},
|
||||
hdi: {
|
||||
id: 'hdi',
|
||||
client: 'HDI Specialty',
|
||||
role: 'Senior fullstack / DevOps',
|
||||
period: '05/2023 – 01/2024',
|
||||
period: 'May 2023 – January 2024',
|
||||
domain: 'Insurance',
|
||||
headline: 'Exposure management for simulations and BaFin reports',
|
||||
headline: 'Exposure management: simulations, stochastic evaluation and BaFin reporting',
|
||||
summary:
|
||||
'Software that automates simulation sequences to determine specific risks, evaluates simulations stochastically, collects policies centrally and produces evaluations of all policies for BaFin.',
|
||||
'Software that automates simulation sequences to determine specific risks, evaluates them stochastically, collects policies centrally and produces evaluations across 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.',
|
||||
'Exposure management in a regulated insurance setting: risks have to be determined through automated simulation sequences and evaluated stochastically, policies have to be collected centrally, and the whole policy set has to be reported to BaFin. Alongside that, the development, QA and production environments on Azure App Services had drifted apart.',
|
||||
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.',
|
||||
'Angular single-page application with TypeScript, HTML and SCSS/CSS, Ngrx Store for the application state and Ngx Pipes for data handling.',
|
||||
'C# and .NET backend with a RESTful API and Entity Framework for database access.',
|
||||
'Test-driven development, with unit and integration tests in xUnit.',
|
||||
'Azure SQL Server administration, with Okta and OAuth 2 for authentication and authorisation.',
|
||||
'CI/CD pipelines and project management in Azure DevOps, working in Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'Development, QA and production systems were migrated from Azure App Services to a Kubernetes cluster on Azure AKS.',
|
||||
'Development, QA and production environments were migrated from Azure App Services onto a Kubernetes cluster on Azure AKS.',
|
||||
'The evaluations required for BaFin run across the full policy set.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'Ngrx',
|
||||
'Ngrx Store',
|
||||
'Ngx Pipes',
|
||||
'C#',
|
||||
'.NET',
|
||||
@@ -174,7 +208,56 @@ export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseS
|
||||
'Azure AKS',
|
||||
'Azure DevOps',
|
||||
],
|
||||
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure'],
|
||||
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure', 'regulation'],
|
||||
linkLabel: 'Read the case: HDI Specialty',
|
||||
teaser:
|
||||
'Fullstack and DevOps on exposure-management software. Dev, QA and production moved from Azure App Services onto Azure AKS.',
|
||||
},
|
||||
devdays: {
|
||||
id: 'devdays',
|
||||
client: 'Magdeburger Dev Days',
|
||||
role: 'Fullstack developer',
|
||||
period: 'August 2020 – March 2022',
|
||||
domain: 'Events and web',
|
||||
headline: 'A developer event online: visitor site, planning tools and a streaming platform',
|
||||
summary:
|
||||
'An annual developer event needs both halves — the public homepage visitors see, and the internal tools the programme is planned with. For the 2021 edition I built a Kubernetes-based streaming platform so the event could run online.',
|
||||
responsibility:
|
||||
"Fullstack development on the event's public site and its internal planning tools, and the streaming platform for the online edition in 2021.",
|
||||
situation:
|
||||
'Magdeburger Dev Days is an annual developer event. The public homepage carries it towards visitors; the internal planning tools carry the programme behind it. In 2021 the event could not take place on site, so it needed a platform to run on instead.',
|
||||
approach: [
|
||||
'Angular front end with TypeScript, HTML and SCSS/CSS, Ngrx Store for the application state, Ngx Pipes for data handling and Angular Material for the components.',
|
||||
'Node.js supporting the development environment.',
|
||||
'C# and .NET Core backend, with SQL and T-SQL data work on Microsoft SQL Server 2019.',
|
||||
'A Kubernetes-based streaming platform for the 2021 edition, so the event could be held online.',
|
||||
'CI/CD pipelines for automated tests and deployments, working in Scrum.',
|
||||
],
|
||||
outcome: [
|
||||
'The 2021 edition ran online, on the streaming platform.',
|
||||
'The public homepage and the internal planning tools were developed and maintained together.',
|
||||
],
|
||||
metrics: [],
|
||||
stack: [
|
||||
'Angular',
|
||||
'TypeScript',
|
||||
'HTML',
|
||||
'SCSS',
|
||||
'Ngrx Store',
|
||||
'Ngx Pipes',
|
||||
'Angular Material',
|
||||
'Node.js',
|
||||
'C#',
|
||||
'.NET Core',
|
||||
'SQL',
|
||||
'T-SQL',
|
||||
'Microsoft SQL Server 2019',
|
||||
'Kubernetes',
|
||||
'GitLab CI/CD',
|
||||
],
|
||||
tags: ['events', 'fullstack', 'Kubernetes', 'streaming'],
|
||||
linkLabel: 'Read the case: Magdeburger Dev Days',
|
||||
teaser:
|
||||
'Visitor site and internal planning tools for an annual developer event, plus a Kubernetes streaming platform for 2021.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,103 +1,108 @@
|
||||
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.',
|
||||
'Servers, networks and infrastructure first, then platforms, software and AI workflows. One technical contact from the first briefing through to operation.',
|
||||
hero: {
|
||||
headline: 'Fullstack and DevOps engineer based in Tangermünde',
|
||||
headline: 'You say what the business needs. I take care of the systems it runs on.',
|
||||
proof:
|
||||
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
|
||||
playfulLine: 'Full-stack, full throttle',
|
||||
'At Fahrschule Bannier I have run the whole IT since roughly 2018. At Elbwärme the infrastructure, Microsoft 365 and the website have been mine since November 2025.',
|
||||
playfulLine: 'Coffee, cables, code',
|
||||
body: [
|
||||
'You do not need the technical vocabulary. Describe what has to keep working, what keeps breaking, or what you want to be able to do next year.',
|
||||
'From there it is my job: the servers and the network first, then the platform they run on, then the software on top — and an AI workflow only where it earns its place.',
|
||||
],
|
||||
},
|
||||
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: [
|
||||
serviceAreasHeading: 'Where I can help',
|
||||
serviceAreas: [
|
||||
{
|
||||
id: 'experience-years',
|
||||
value: 'about 7 years',
|
||||
label: 'professional experience with web applications',
|
||||
id: 'infrastructure-network',
|
||||
title: 'Servers and networks',
|
||||
body: 'Servers, virtualisation and the network your company works on every day — on your own hardware where that is the right answer.',
|
||||
routeId: 'services',
|
||||
fragment: 'infrastructure-network',
|
||||
},
|
||||
{
|
||||
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: 'platform-operations',
|
||||
title: 'Platforms and operations',
|
||||
body: 'Microsoft 365, containers and Kubernetes, pipelines, and the arrangement that keeps them running after go-live.',
|
||||
routeId: 'services',
|
||||
fragment: 'platform-operations',
|
||||
},
|
||||
{
|
||||
id: 'myspa-team-lead',
|
||||
value: '4 + 2',
|
||||
label: 'led the backend team and the DevOps team through the MySpa launch',
|
||||
caseId: 'myspa',
|
||||
id: 'software-data',
|
||||
title: 'Software and data',
|
||||
body: 'Product software from the domain through the API to the screen, plus SQL and T-SQL data work and migrations.',
|
||||
routeId: 'services',
|
||||
fragment: 'software-data',
|
||||
},
|
||||
{
|
||||
id: 'ai-workflows',
|
||||
title: 'AI workflows',
|
||||
body: 'Two tools are delivered and in daily use at Rösterei Tangermünde. Everything beyond those two is scoped and reviewed for your case first.',
|
||||
routeId: 'services',
|
||||
fragment: 'ai-workflows',
|
||||
},
|
||||
],
|
||||
audiences: [
|
||||
processHeading: 'How the work runs',
|
||||
process: [
|
||||
{
|
||||
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: 'describe',
|
||||
title: 'You describe the situation',
|
||||
body: 'In your own words, on a call or in a short briefing. Technical vocabulary is not the entry ticket.',
|
||||
},
|
||||
{
|
||||
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' },
|
||||
],
|
||||
id: 'diagnose',
|
||||
title: 'I look at it and say what I think',
|
||||
body: 'Which area it belongs in, what it will realistically take, and which part of it is not mine to do.',
|
||||
},
|
||||
{
|
||||
id: 'implement',
|
||||
title: 'The work gets done',
|
||||
body: 'Infrastructure, platform, software or a clearly bounded AI tool — with the same person on it who took the briefing.',
|
||||
},
|
||||
{
|
||||
id: 'operate',
|
||||
title: 'Operation or handover',
|
||||
body: 'Either I keep running it, or your team takes it over from structures that are written down. Which of the two it is gets agreed in advance.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
|
||||
systemsMap: {
|
||||
heading: 'How the layers connect',
|
||||
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
|
||||
},
|
||||
proofCaseId: 'roesterei',
|
||||
proofHeading: 'From the network to the shop, inside one company',
|
||||
proofNote: 'I hold an economic participation in Rösterei Tangermünde.',
|
||||
featuredCaseIds: ['bannier', 'elbwaerme', 'hdi', 'innofocus'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'Thirty seconds',
|
||||
id: 'areas',
|
||||
headline: 'Four areas, in that order',
|
||||
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.',
|
||||
'The order is not decoration: the infrastructure carries the platform, the platform carries the software, and an AI workflow only makes sense once the three below it are steady.',
|
||||
'Work that falls outside those four areas gets named in the briefing rather than quietly sold.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Selected cases',
|
||||
id: 'team',
|
||||
headline: 'Who you are actually talking to',
|
||||
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.',
|
||||
'I am the technical contact, from the first call through to operation. Nobody hands you on to somebody you have not met.',
|
||||
'We are two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and handles the commercial and organisational side; he is not a second engineer or developer. Beyond the two of us sits a curated network of specialists I trust, so a brief that suits one of them better gets said out loud and is either passed on or delivered together.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'proof',
|
||||
headline: 'Why the roastery is the proof case',
|
||||
body: [
|
||||
'Rösterei Tangermünde is the one company where the whole chain is mine: network and server structures, the everyday services, the online shop, and two AI tools in internal use.',
|
||||
'The other cases each carry one area. They are listed with their period and their scope, and nothing more than that.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Services', routeId: 'services' },
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||
{ label: 'See the four areas', routeId: 'services' },
|
||||
{ label: 'Servers and networks', routeId: 'services', fragment: 'infrastructure-network' },
|
||||
{ label: 'The Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -11,30 +11,31 @@ 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.',
|
||||
'Six public cases: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty and the Magdeburger Dev Days. The full curriculum vitae is available as a download.',
|
||||
hero: {
|
||||
headline: 'Four public cases, deliberately chosen',
|
||||
headline: 'Six 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',
|
||||
headline: 'Why only these six',
|
||||
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.',
|
||||
'Six cases are public: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty and the Magdeburger Dev Days. They are the ones I can describe in full, with a client name attached, and together they cover the four areas of work.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
'Every case names the client, the role, the period, the situation, the approach and the outcome. Figures appear only where the project produced them.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
caseLabels: {
|
||||
@@ -69,7 +70,7 @@ export const STACK_EN: StackPageCopy = {
|
||||
id: 'groups',
|
||||
headline: 'Which groups the work touches',
|
||||
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.',
|
||||
'Product work in C# and .NET and in Java with Spring, Angular on the front end. Data work in SQL Server with T-SQL. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in Azure DevOps and GitLab CI/CD, GitOps with Argo CD. Servers, networks and virtualisation on site, plus Microsoft 365 in day-to-day operation.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -87,18 +88,18 @@ export const STACK_EN: StackPageCopy = {
|
||||
{ id: 'tools', title: 'Tools' },
|
||||
],
|
||||
evidenceNote:
|
||||
'The public cases evidence items such as Angular, TypeScript, C#, .NET, Java, Spring, SQL Server, MySQL, MariaDB, Docker, Kubernetes, Azure, Azure DevOps, GitLab, Proxmox and Microsoft 365. The remaining entries show breadth and familiarity without a public reference.',
|
||||
'The public cases evidence Angular, TypeScript, SCSS, C#, .NET, Entity Framework, xUnit, Java with Spring WebFlux, Microsoft SQL Server, T-SQL, MariaDB, Node.js, Docker, Kubernetes, Proxmox, Azure AKS, Azure SQL Server, Azure DevOps, GitLab CI/CD, Argo CD, Keycloak, Okta, OAuth 2, Microsoft 365 and Shopify with Liquid and Hydrogen. The remaining entries in the grid show breadth and familiarity, without a public case behind them.',
|
||||
};
|
||||
|
||||
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.',
|
||||
'Antonio Ledebuhr, fullstack and DevOps engineer in Tangermünde. Freelance since April 2023, a two-person core team, 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.',
|
||||
'Based in Tangermünde, freelance since April 2023. German native speaker, English at negotiation level.',
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
@@ -117,23 +118,26 @@ export const ABOUT_EN: PageCopy = {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contact-and-language',
|
||||
headline: 'Customer contact, teams and languages',
|
||||
id: 'contact-and-team',
|
||||
headline: 'Who you deal with, and who else is involved',
|
||||
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.',
|
||||
'I am the technical point of contact, from the first requirements conversation through to production. That has been part of the work from the start, not something added later.',
|
||||
'The core team is two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and covers the commercial and organisational side; he is not a second engineer or developer.',
|
||||
'Around us sits a curated network of specialists, so a brief that fits somebody else better is passed on openly or delivered jointly. Fit decides that, not availability.',
|
||||
'German is my 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.',
|
||||
'The roastery is the personal part of the work, and it is where a good share of my own time goes. I hold an economic participation in Rösterei Tangermünde.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Projects', routeId: 'projects' },
|
||||
{ label: 'For recruiters', routeId: 'pitch' },
|
||||
{ label: 'Contact', routeId: 'contact' },
|
||||
],
|
||||
};
|
||||
@@ -160,7 +164,7 @@ export const CONTACT_EN: ContactPageCopy = {
|
||||
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.',
|
||||
'If the briefing is more structure than you need, write to info@antoniolede.de and describe it in your own words. I answer myself; there is no sales layer in between.',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -173,13 +177,15 @@ export const CONTACT_EN: ContactPageCopy = {
|
||||
id: 'projectType',
|
||||
control: 'select',
|
||||
label: 'Type of work',
|
||||
required: true,
|
||||
hint: 'optional — pick “Not sure yet” if you would rather describe it below',
|
||||
required: false,
|
||||
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' },
|
||||
{ value: 'unsure', label: 'Not sure yet' },
|
||||
{ value: 'infrastructure-network', label: 'Servers and networks' },
|
||||
{ value: 'platform-operations', label: 'Platform and operations' },
|
||||
{ value: 'software-data', label: 'Software and data' },
|
||||
{ value: 'ai-workflows', label: 'AI workflows' },
|
||||
{ value: 'other', label: 'Something else' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -198,7 +204,6 @@ export const CONTACT_EN: ContactPageCopy = {
|
||||
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.',
|
||||
};
|
||||
@@ -357,8 +362,7 @@ export const NOT_FOUND_EN: PageCopy = {
|
||||
'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.',
|
||||
proof: 'The address does not match a public route. The links below lead back into the site.',
|
||||
},
|
||||
sections: [],
|
||||
ctas: [
|
||||
|
||||
135
src/app/core/content/en/pitch.ts
Normal file
135
src/app/core/content/en/pitch.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type PitchPageCopy } from '../content.contracts';
|
||||
|
||||
export const PITCH_EN: PitchPageCopy = {
|
||||
routeId: 'pitch',
|
||||
title: 'Profile | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack and DevOps engineer in Tangermünde: building and operating web applications, direct customer contact and team responsibility. Freelance since April 2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack and DevOps engineer in Tangermünde',
|
||||
proof:
|
||||
'Building and operating web applications, from the requirement through to production. Freelance since April 2023.',
|
||||
playfulLine: 'Full-stack, full throttle',
|
||||
},
|
||||
profile: [
|
||||
'Building and operating web applications is the core of the work, from the data model through to production.',
|
||||
'Fullstack development, DevOps, direct customer contact from the first requirements conversation through to production, and team responsibility.',
|
||||
'Freelance since April 2023, based in Tangermünde.',
|
||||
'Java with Spring is where I work fastest; alongside it C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, GitLab CI/CD and Azure DevOps.',
|
||||
'Today the technical point of contact in a two-person core team between Tangermünde and Magdeburg; the commercial side sits with my colleague, the technology with me, and a curated network of specialists sits around the two of us.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → about 90 min',
|
||||
label: 'data-migration run time at Innofocus / reifen.com',
|
||||
note: 'at a larger feature scope and higher data quality',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
timelineHeading: 'Stations',
|
||||
timeline: [
|
||||
{
|
||||
id: 'bannier',
|
||||
period: 'since roughly 2018',
|
||||
role: 'IT support and operation at Fahrschule Bannier',
|
||||
body: 'The whole IT of a driving school, including support and operation of the specialist software it uses. That software comes from its vendor; I did not develop it.',
|
||||
},
|
||||
{
|
||||
id: 'devdays',
|
||||
period: 'August 2020 – March 2022',
|
||||
role: 'Fullstack developer, Magdeburger Dev Days',
|
||||
body: 'Visitor site and internal planning tools for an annual developer event, plus a Kubernetes-based streaming platform for the 2021 edition.',
|
||||
},
|
||||
{
|
||||
id: 'self-employed',
|
||||
period: 'since December 2021',
|
||||
role: 'running my own business',
|
||||
body: 'Own responsibility for the brief, the technology and operations, including invoicing and client relationships.',
|
||||
},
|
||||
{
|
||||
id: 'freelance',
|
||||
period: 'since April 2023',
|
||||
role: 'freelance',
|
||||
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements conversation through to production.',
|
||||
},
|
||||
{
|
||||
id: 'hdi',
|
||||
period: 'May 2023 – January 2024',
|
||||
role: 'Senior fullstack / DevOps at HDI Specialty',
|
||||
body: 'Fullstack and DevOps ownership in a regulated insurance setting, including the move onto Azure AKS.',
|
||||
},
|
||||
{
|
||||
id: 'roesterei',
|
||||
period: 'since October 2023',
|
||||
role: 'Full technical responsibility at Rösterei Tangermünde',
|
||||
body: "One contact for the company's technology as a whole. The economic participation stays disclosed.",
|
||||
},
|
||||
{
|
||||
id: 'innofocus',
|
||||
period: 'since September 2025',
|
||||
role: 'Senior backend and database engineer at Innofocus / reifen.com',
|
||||
body: 'Sole ownership of the migration, as the only freelancer in direct contact with the end customer.',
|
||||
},
|
||||
{
|
||||
id: 'elbwaerme',
|
||||
period: 'since November 2025',
|
||||
role: 'Infrastructure and IT support at Elbwärme',
|
||||
body: 'Infrastructure, Microsoft 365 and the website; the responsibility has been mine since November 2025.',
|
||||
},
|
||||
],
|
||||
stackHeading: 'Core stack',
|
||||
coreStack: [
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java and Spring',
|
||||
body: 'Home stack: Java with Spring Boot, Spring Data and tests.',
|
||||
},
|
||||
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
|
||||
{
|
||||
id: 'angular',
|
||||
title: 'Angular and TypeScript',
|
||||
body: 'Angular SPAs with RxJS and SCSS.',
|
||||
},
|
||||
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
|
||||
{
|
||||
id: 'containers',
|
||||
title: 'Docker and Kubernetes',
|
||||
body: 'Containers and clusters on-premise and in the cloud.',
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
title: 'Azure including AKS',
|
||||
body: 'Azure AKS, Azure DevOps and related cloud services.',
|
||||
},
|
||||
{
|
||||
id: 'cicd',
|
||||
title: 'GitLab CI/CD and Azure DevOps',
|
||||
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In thirty seconds',
|
||||
body: [
|
||||
'I work as a fullstack and DevOps engineer out of Tangermünde, freelance since April 2023. The CV as a PDF lists every station in full.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Selected cases',
|
||||
body: [
|
||||
'The public cases cover ERP and eCommerce, company IT and infrastructure, insurance and an annual developer event. Each one is written out in full on the projects page.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Selected projects', routeId: 'projects' },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||
],
|
||||
};
|
||||
@@ -1,436 +1,416 @@
|
||||
import { type PageCopy, type ServicePageCopy } from '../content.contracts';
|
||||
import { type ServicesPageCopy } from '../content.contracts';
|
||||
|
||||
export const SERVICES_OVERVIEW_EN: PageCopy = {
|
||||
export const SERVICES_EN: ServicesPageCopy = {
|
||||
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.',
|
||||
'Servers and networks, platform operations, software and data, AI workflows. One technical contact from the first briefing through to operation and handover.',
|
||||
hero: {
|
||||
headline: 'Four service areas, one counterpart',
|
||||
headline: 'Four areas of work, one technical contact',
|
||||
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',
|
||||
'Servers, networks and virtualisation; platform operations; software and data; AI workflows. Every area names the public cases that back it.',
|
||||
playfulLine: 'Cables before clouds',
|
||||
body: [
|
||||
'I start where the infrastructure actually sits: the servers, the network, the machines a company depends on every morning. From there the work moves upward — the platform they run on, the software on top, and an AI workflow only where it earns its place.',
|
||||
'Every area below follows the same order: the situation, the diagnosis, the implementation, then operation and handover. The public cases sit at the end of each area, so you can check the claim against real work.',
|
||||
],
|
||||
},
|
||||
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',
|
||||
id: 'approach',
|
||||
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.',
|
||||
'Situation, diagnosis, implementation, operation and handover. The order is the same in all four areas, which is what makes them comparable.',
|
||||
'Nothing starts with a product name. It starts with what the business has to keep doing on Monday morning, and with the layer that carries it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
headline: 'Who you actually work with',
|
||||
body: [
|
||||
'I am your technical point of contact. You talk to me about the brief, about the work and about the invoice, and I stay the same person through diagnosis, implementation and operation.',
|
||||
'The core team is two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and takes on the commercial and organisational side, including the paperwork. He is not a second engineer or developer, and I do not present him as one.',
|
||||
'Around that sits a curated network of specialists. When a brief fits one of them better, I say so, and either hand it over openly or deliver it together with them. Availability does not decide the answer; fit does.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'boundaries',
|
||||
headline: 'What I do not sell along with it',
|
||||
body: [
|
||||
'Work that falls outside an area is named in the briefing, not in the small print. If a change is not needed, or the effort is not worth the result, you hear that from me before anybody writes an invoice.',
|
||||
'Delivered work and what is on offer are listed separately in every area. Where a figure is quoted, it comes from the project it belongs to. Where there is no figure, there is no figure.',
|
||||
],
|
||||
},
|
||||
],
|
||||
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' },
|
||||
{ label: 'Servers and networks', routeId: 'services', fragment: 'infrastructure-network' },
|
||||
{ label: 'Platform and operations', routeId: 'services', fragment: 'platform-operations' },
|
||||
{ label: 'Software and data', routeId: 'services', fragment: 'software-data' },
|
||||
{ label: 'AI workflows', routeId: 'services', fragment: 'ai-workflows' },
|
||||
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||
{ label: 'See the public cases', routeId: 'projects' },
|
||||
],
|
||||
};
|
||||
|
||||
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: [],
|
||||
sectionsHeading: 'The four areas',
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
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: [],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
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: [],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered and validated per engagement',
|
||||
offerOfferingsHeading: 'Offered, scoped per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
evidenceHeading: 'Public proof for this area',
|
||||
inquiryHeading: 'Send a briefing',
|
||||
systemsMap: {
|
||||
heading: 'How the four areas connect',
|
||||
intro:
|
||||
'Infrastructure carries the platform, the platform carries the software, and the AI work sits on top of all three. The public cases hang off the area they belong to.',
|
||||
},
|
||||
serviceSections: [
|
||||
{
|
||||
id: 'infrastructure-network',
|
||||
title: 'Servers, networks and on-premise infrastructure',
|
||||
lead: 'The layer everything else stands on — servers, the network, virtualisation and the on-premise structures a company runs its day on.',
|
||||
situation: {
|
||||
id: 'infrastructure-network-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Infrastructure that grew rather than got planned is the normal starting point. One server quietly does several jobs, the switch in the back room has no documentation, and whoever set it up left years ago.',
|
||||
'It usually still works. The cost shows up as small delays, unclear responsibility, and a bad week whenever something fails.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'infrastructure-network-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I start by writing down what actually runs: which machines exist, what depends on them, and which paths have to stay up during working hours.',
|
||||
'Then I separate what is worth keeping from what is only still there because nobody dared to touch it. That list is what we decide on — not a product catalogue.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'infrastructure-network-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Server structures and virtualisation sized for the company in front of me, a network with a shape somebody can follow, and on-premise systems where keeping the data in the building is the point. At Rösterei Tangermünde the virtualisation runs on Proxmox.',
|
||||
'On-premise stays a decision rather than a fallback. I prefer boring, maintainable building blocks, and if a smaller setup does the job, that is what gets built.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'infrastructure-network-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Afterwards I stay on it, or an internal team takes over from written structures. Handover means named responsibilities and paths somebody else can follow; nothing in the infrastructure should depend on my memory.',
|
||||
'The employee workstations and the everyday devices hanging off that infrastructure are part of the same operational work — setup, replacement and day-to-day support, where that is part of the arrangement. They are a subordinate task, not what the area is about.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'infra-network-and-server-structures',
|
||||
title: 'Network and server structures',
|
||||
body: 'Networks and server structures built to fit the company they serve: clear segments, documented paths, and wiring somebody else can follow. At Rösterei Tangermünde that layer has been mine since October 2023.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-virtualisation-on-premise',
|
||||
title: 'Virtualisation on your own hardware',
|
||||
body: 'Several services on your own hardware instead of several boxes. Virtualisation with Proxmox makes maintenance, moves and recovery predictable without handing the systems out of the building.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-whole-it-small-business',
|
||||
title: 'The whole IT of a small company',
|
||||
body: 'One contact for all of it, over years rather than a project window. At Fahrschule Bannier that arrangement has run since roughly 2018. I support and operate the specialist software the school uses; I did not develop it.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'bannier',
|
||||
},
|
||||
{
|
||||
id: 'infra-infrastructure-responsibility',
|
||||
title: 'Infrastructure under one responsibility',
|
||||
body: 'Infrastructure, the everyday services and the access to them under one responsibility, instead of three separate arrangements. At Elbwärme that has been mine since November 2025.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'elbwaerme',
|
||||
},
|
||||
{
|
||||
id: 'infra-inventory-and-risk-review',
|
||||
title: 'A look at the infrastructure before you decide',
|
||||
body: 'A read of what you already have before money is committed to new hardware or a move, with the single points of failure written down. Scope and depth are agreed per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'infrastructure-network-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'Fahrschule Bannier is the longest-running of the six public cases: the whole IT of the driving school, accompanied since roughly 2018. Elbwärme is the most recent — infrastructure, Microsoft 365 and the website since November 2025. At Rösterei Tangermünde the same work sits inside full technical responsibility, starting at the network and server structures.',
|
||||
caseIds: ['bannier', 'elbwaerme', 'roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'infrastructure-network-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what runs today, what has to stay available during working hours, and what prompted the question.',
|
||||
'The briefing form only opens your local mail client with the text filled in. Nothing is sent to this server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'platform-operations',
|
||||
title: 'Platforms, containers and operations',
|
||||
lead: 'The layer where software actually runs — Microsoft 365, containers, Kubernetes, pipelines, and the arrangement that keeps them running afterwards.',
|
||||
situation: {
|
||||
id: 'platform-operations-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Applications sit on single hosts or on a managed service, deployment is a manual routine, and the development, test and production environments have drifted apart.',
|
||||
'The Microsoft 365 tenant is often in the same state: it works, but nobody can say who owns it or what is switched on. That is platform operations too, under a different name.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'platform-operations-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I look at what has to be identical across environments, what genuinely needs a cluster, and what is fine exactly as it is.',
|
||||
'A cluster is not the goal. If a container on one host and a working pipeline solve the problem, the cluster stays in the drawer.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'platform-operations-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Containers with Docker, Kubernetes on-premise or on Azure AKS, and the migration of development, QA and production onto it. At HDI Specialty those three environments moved off Azure App Services onto Azure AKS.',
|
||||
'Pipelines with Azure DevOps or GitLab CI/CD, and GitOps with Argo CD where the repository should be the source of truth. Microsoft 365 belongs in the same picture: the tenant, the accounts, and the services a company uses every day.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'platform-operations-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Operation means keeping the platform boring: rollouts that behave the same way every time, and a state you can read out of Git rather than off one machine.',
|
||||
'Handover is written down and walked through with the people taking it on. Where accounts, employee devices and the everyday services are part of that arrangement, they stay a subordinate operational task — not the reason the platform exists.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'platform-microsoft-365',
|
||||
title: 'Microsoft 365 set up and operated',
|
||||
body: 'Tenant, accounts and the everyday services set up and operated, so the people using them do not have to think about them. At Rösterei Tangermünde and at Elbwärme that layer is mine.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'platform-containers-and-kubernetes',
|
||||
title: 'Containers and Kubernetes',
|
||||
body: 'Docker and Kubernetes on-premise or on Azure AKS, including the day-to-day operation of what runs on the cluster. At HDI Specialty the development, QA and production environments moved off Azure App Services onto Azure AKS.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'platform-pipelines-and-gitops',
|
||||
title: 'Build pipelines and GitOps',
|
||||
body: 'Build, test and deploy as one repeatable process: pipelines in Azure DevOps or GitLab CI/CD, deployments driven from the repository through Argo CD. That is how the Innofocus / reifen.com application reaches its cluster.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'platform-aligning-environments',
|
||||
title: 'Bringing environments back into line',
|
||||
body: 'Development, test and production brought onto the same footing, so a fault does not first appear in production. The cut depends on the tooling already in place and is agreed up front.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'platform-handover-to-your-team',
|
||||
title: 'A handover your team can actually use',
|
||||
body: 'Written runbooks and a walk-through with the people taking the platform on: access, routines, recurring tasks, who to call. The scope is agreed per engagement, because it depends on who is taking over.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'platform-operations-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'At HDI Specialty the development, QA and production environments were migrated from Azure App Services onto a Kubernetes cluster on Azure AKS, with the pipelines in Azure DevOps. At Rösterei Tangermünde the platform layer — Microsoft 365, Docker, Kubernetes and GitLab CE — sits inside my full technical responsibility. At Elbwärme it is the infrastructure and Microsoft 365, since November 2025.',
|
||||
caseIds: ['hdi', 'roesterei', 'elbwaerme'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'platform-operations-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what runs where today, how a release reaches production, and which environments are supposed to match.',
|
||||
'The briefing only opens your local mail client. Nothing reaches this server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'software-data',
|
||||
title: 'Product software, APIs and data',
|
||||
lead: 'Fullstack product work and the data underneath it — APIs, SQL and T-SQL, and migrations that have to survive an audit.',
|
||||
situation: {
|
||||
id: 'software-data-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'A product needs something the current structure cannot carry. Or the data sits in a schema that grew for years and now blocks every change.',
|
||||
'Both usually arrive as one sentence from the business side, not as a specification.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'software-data-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I take that sentence apart into the domain, the data and the interfaces, in that order, and say plainly which part is the expensive one.',
|
||||
'For data work that means reading the legacy structure before touching it: what is real data, what is a workaround somebody typed in years ago, and what an auditor will need to see.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'software-data-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Fullstack delivery with Angular and TypeScript on the surface, C# and .NET or Java and Spring behind it, REST APIs, and sign-in through Keycloak or Okta with OAuth 2 and OIDC.',
|
||||
'Data work in Microsoft SQL Server, with T-SQL stored procedures for transformation, normalisation and cleansing. At Innofocus / reifen.com the migration run went from around eight hours to about 90 minutes, at a larger feature scope and higher data quality.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'software-data-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Software is handed over with its tests, its migration scripts and the documentation the next person needs. A migration nobody else can rerun is not finished.',
|
||||
'Where I stay on afterwards, that covers the running system, the data behind it and the next migration round. You keep the same contact you had while it was written.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'software-fullstack-product-software',
|
||||
title: 'Fullstack product software',
|
||||
body: 'Angular on the front, C# and .NET or Java and Spring behind it, a REST API, and tests written alongside the code rather than after it. That is how the exposure-management software at HDI Specialty was built.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'software-legacy-data-migration',
|
||||
title: 'Legacy data migration',
|
||||
body: 'Moving a grown data store into a normalised target structure inside Microsoft SQL Server, including the documentation an auditor asks for, and picking it up where another provider left off. At Innofocus / reifen.com the migration is mine alone, and a run takes about 90 minutes instead of around eight hours, at a larger feature scope and higher data quality.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-sql-and-tsql-data-work',
|
||||
title: 'T-SQL data work',
|
||||
body: 'Stored procedures for transformation, normalisation and cleansing, written where the data already sits rather than routed through the application, and written so the run can be repeated and checked rather than trusted.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-replacing-a-legacy-system',
|
||||
title: 'Replacing a system that is still running',
|
||||
body: 'Replacing a grown system step by step without stopping the business. The order, the cut and the way back are agreed before the first line of code.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'software-data-model-and-api-review',
|
||||
title: 'A review of the data model and the APIs',
|
||||
body: 'A written assessment of the data model, the interfaces and the test coverage of an existing application, with the risks named and put in order. Scope is agreed up front.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'software-data-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'Innofocus / reifen.com carries this area: sole responsibility for moving a grown ERP data store into a normalised system, with the migration run down from around eight hours to about 90 minutes at a larger feature scope. The Magdeburger Dev Days work covers the other half — the public site plus the internal planning tools behind it, and a Kubernetes-based streaming platform for the 2021 edition. HDI Specialty shows the same fullstack shape under regulation, with evaluations across the whole policy set for BaFin.',
|
||||
caseIds: ['innofocus', 'devdays', 'hdi'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'software-data-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what the software or the data has to do, what exists today, and whether there is a date it has to be ready for.',
|
||||
'The briefing assembles a message in your local mail client. It is not sent from this site.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ai-workflows',
|
||||
title: 'AI workflows',
|
||||
lead: 'Two AI tools are running in daily use at Rösterei Tangermünde. Everything beyond those two is an offer that gets scoped, and reviewed, for your case.',
|
||||
situation: {
|
||||
id: 'ai-workflows-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'A process eats time every week: sorting through customer reviews, repeating the same handful of steps in a shop backend, moving text from one place into another.',
|
||||
'Whether a model helps depends entirely on that process. Often the honest answer arrives before any model does.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'ai-workflows-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'The process comes first: how often it runs, what a wrong answer costs, and which data would have to leave the building for a model to see it.',
|
||||
'If the process itself is the problem, I say so. A model laid over a broken process only makes the mess faster.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'ai-workflows-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Where it holds up, a narrow tool goes into the chain that already exists. At Rösterei Tangermünde there are two of them: one categorises customer reviews, one speeds up managing the new shop — comparable to Shopify Sidekick and wired into the same internal tooling.',
|
||||
'Everything beyond those two — process analysis, external models and APIs, a locally run model, retrieval over your own documents, a person keeping approval, several steps handing over to each other — is offered and scoped per engagement. I do not present it as delivered client work.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'ai-workflows-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
"The two delivered tools live inside the roastery's own tool chain and are maintained there, alongside the rest of its technology.",
|
||||
'Handover means a readable integration: where the tool sits, what it is allowed to see, what gets logged, and how to switch it off. Not a chat window bolted on beside the shop.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'ai-review-categorisation',
|
||||
title: 'Categorising customer reviews',
|
||||
body: 'An AI tool that sorts incoming customer reviews into categories. Delivered for Rösterei Tangermünde and running inside its internal tooling.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-shop-management-assistant',
|
||||
title: 'Assistant for shop management',
|
||||
body: 'An AI tool for managing the new shop quickly, comparable to Shopify Sidekick and integrated into the same internal tool chain. Delivered for Rösterei Tangermünde.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-process-analysis-first',
|
||||
title: 'Process analysis before any model',
|
||||
body: "We open the process up first and decide whether a model belongs in it at all. The answer is worked out for your case; it is not carried over from somebody else's.",
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-external-or-local-models',
|
||||
title: 'External models, APIs or a local model',
|
||||
body: 'Connecting an existing model or API to a process, or checking whether running a model on your own hardware is viable. Offered and scoped per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-retrieval-with-data-boundary',
|
||||
title: 'Retrieval over your own documents, with a person in the loop',
|
||||
body: 'If internal texts should ground the answers, the retrieval path and the data boundary are designed against your corpus, and we agree where a person keeps approval. Scoped per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-human-approval-and-multi-step',
|
||||
title: 'Several specialised steps in one workflow',
|
||||
body: 'Where a task splits into separate steps that hand over to each other, and where a person approves or corrects, the composition is sketched and reviewed against your brief before any of it is built.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'ai-workflows-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: "Rösterei Tangermünde is the only public case behind this area, and it carries exactly two delivered tools: the review categorisation and the shop assistant. Both run inside the company's own tool chain, not as a product. Everything else listed in this area is an offer, scoped per engagement. I hold an economic participation in Rösterei Tangermünde, which is why that is stated on the case itself.",
|
||||
caseIds: ['roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'ai-workflows-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: which process is costing time, how often it runs, and which data is not allowed to leave the company.',
|
||||
'The briefing only opens your local mail client. Nothing is sent to this server, which is also the honest starting point for a conversation about data.',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_EN } from './cases';
|
||||
import { HOME_EN } from './home';
|
||||
import { PITCH_EN } from './pitch';
|
||||
import {
|
||||
ABOUT_EN,
|
||||
CONTACT_EN,
|
||||
@@ -10,22 +11,12 @@ import {
|
||||
PROJECTS_EN,
|
||||
STACK_EN,
|
||||
} from './pages';
|
||||
import {
|
||||
SERVICES_AI_EN,
|
||||
SERVICES_CLUSTERS_EN,
|
||||
SERVICES_HARDWARE_EN,
|
||||
SERVICES_OVERVIEW_EN,
|
||||
SERVICES_SOFTWARE_EN,
|
||||
} from './services';
|
||||
import { SERVICES_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,
|
||||
},
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_EN,
|
||||
cases: CASES_EN,
|
||||
contact: CONTACT_EN,
|
||||
projects: PROJECTS_EN,
|
||||
@@ -36,17 +27,14 @@ export const SITE_CONTENT_EN: SiteContent = {
|
||||
},
|
||||
seo: {
|
||||
jobTitle: 'Fullstack and DevOps engineer',
|
||||
professionalServiceName: 'Software and DevOps services',
|
||||
professionalServiceName: 'Infrastructure, software and DevOps services',
|
||||
professionalServiceDescription:
|
||||
'Four service areas covering product software, infrastructure, clusters and AI integration — from the first workshop through to operations, with direct customer contact.',
|
||||
'Servers and networks, platform operations, software and data, and AI workflows — with one technical contact from the first briefing through to operation.',
|
||||
},
|
||||
pages: {
|
||||
home: HOME_EN,
|
||||
services: SERVICES_OVERVIEW_EN,
|
||||
servicesSoftware: SERVICES_SOFTWARE_EN,
|
||||
servicesHardwareNetwork: SERVICES_HARDWARE_EN,
|
||||
servicesClusters: SERVICES_CLUSTERS_EN,
|
||||
servicesAi: SERVICES_AI_EN,
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_EN,
|
||||
projects: PROJECTS_EN,
|
||||
stack: STACK_EN,
|
||||
about: ABOUT_EN,
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface ShellCopy {
|
||||
readonly menuClose: string;
|
||||
readonly languageSwitch: string;
|
||||
readonly otherLocaleName: string;
|
||||
readonly cvLabel: string;
|
||||
readonly contactCta: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Menü schließen',
|
||||
languageSwitch: 'Zur englischen Version wechseln',
|
||||
otherLocaleName: 'English',
|
||||
cvLabel: 'Lebenslauf als PDF',
|
||||
contactCta: 'Kontakt',
|
||||
},
|
||||
en: {
|
||||
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Close menu',
|
||||
languageSwitch: 'Switch to the German version',
|
||||
otherLocaleName: 'Deutsch',
|
||||
cvLabel: 'Curriculum vitae as PDF',
|
||||
contactCta: 'Contact',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => {
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||
const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
|
||||
@@ -2,27 +2,40 @@ import { type CommandId } from '../commands/command-ids';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly palette: {
|
||||
readonly terminal: {
|
||||
readonly triggerLabel: string;
|
||||
readonly triggerAccessibleName: string;
|
||||
readonly triggerTooltip: string;
|
||||
readonly triggerTooltipApple: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly shortcutHintApple: string;
|
||||
readonly dialogTitle: string;
|
||||
readonly dialogDescription: string;
|
||||
readonly prompt: string;
|
||||
readonly panelLabel: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly closeLabel: string;
|
||||
readonly suggestionsLabel: string;
|
||||
readonly collapseLabel: string;
|
||||
readonly collapseTooltip: string;
|
||||
readonly maximizeLabel: string;
|
||||
readonly maximizeTooltip: string;
|
||||
readonly restoreLabel: string;
|
||||
readonly restoreTooltip: string;
|
||||
readonly outputLabel: string;
|
||||
readonly emptySuggestions: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly unknownTarget: string;
|
||||
readonly validTargetsLabel: string;
|
||||
readonly helpIntro: string;
|
||||
readonly helpGrammar: string;
|
||||
readonly helpTargetsIntro: string;
|
||||
readonly historyEmpty: string;
|
||||
readonly historyIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly jumping: string;
|
||||
readonly incompleteHint: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly ignite: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
@@ -33,11 +46,17 @@ export interface SignatureCopy {
|
||||
readonly listHeading: string;
|
||||
readonly legendHeading: string;
|
||||
readonly relationshipLabel: string;
|
||||
readonly dialogTitlePattern: string;
|
||||
readonly closeLabel: string;
|
||||
readonly closeTooltip: string;
|
||||
readonly sectionLinkPattern: string;
|
||||
readonly caseLinkPattern: string;
|
||||
readonly nodeLinkHint: string;
|
||||
readonly legend: {
|
||||
readonly ai: string;
|
||||
readonly cluster: string;
|
||||
readonly hardware: string;
|
||||
readonly infrastructure: string;
|
||||
readonly platform: string;
|
||||
readonly software: string;
|
||||
readonly ai: string;
|
||||
readonly project: string;
|
||||
};
|
||||
};
|
||||
@@ -45,108 +64,142 @@ export interface SignatureCopy {
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
palette: {
|
||||
triggerLabel: 'Befehle öffnen',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
triggerAccessibleName: 'Terminal öffnen',
|
||||
triggerTooltip: 'Terminal öffnen (Strg+K)',
|
||||
triggerTooltipApple: 'Terminal öffnen (⌘K)',
|
||||
shortcutHint: 'Strg+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Befehle',
|
||||
dialogDescription:
|
||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
closeLabel: 'Schließen',
|
||||
suggestionsLabel: 'Vorschläge',
|
||||
collapseLabel: 'Terminal einklappen',
|
||||
collapseTooltip: 'Einklappen (Esc)',
|
||||
maximizeLabel: 'Terminal vergrößern',
|
||||
maximizeTooltip: 'Vergrößern',
|
||||
restoreLabel: 'Terminal verkleinern',
|
||||
restoreTooltip: 'Verkleinern',
|
||||
outputLabel: 'Ausgabe',
|
||||
emptySuggestions: 'Keine passenden Befehle.',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
unknownTarget: 'Unbekanntes Ziel: {target}',
|
||||
validTargetsLabel: 'Gültige Ziele:',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate <ziel> [<bereich>]',
|
||||
helpTargetsIntro: 'Ziele für navigate:',
|
||||
historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.',
|
||||
historyIntro: 'Eingegebene Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
jumping: 'Sprung zu {target}.',
|
||||
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
|
||||
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.',
|
||||
help: 'Listet die Grammatik und jeden Befehl.',
|
||||
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
|
||||
clear: 'Leert das Ausgabebuch.',
|
||||
brew: 'Brüht einen Kaffee auf. Sonst passiert nichts.',
|
||||
rev: 'Gibt kurz Gas. Sonst passiert nichts.',
|
||||
navigate:
|
||||
'Wechselt zu einer Seite, springt in einen Leistungsbereich oder öffnet den Lebenslauf.',
|
||||
},
|
||||
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',
|
||||
'Wie die vier Leistungsbereiche zusammenhängen — von Server und Netz bis zu den KI-Abläufen, und wo die öffentlichen Fälle ansetzen.',
|
||||
diagramDescription:
|
||||
'Diagramm der vier Leistungsbereiche, der öffentlichen Fälle und ihrer Verbindungen.',
|
||||
listHeading: 'Die Karte als Liste',
|
||||
legendHeading: 'Legende',
|
||||
relationshipLabel: 'Verbunden mit {targets}.',
|
||||
dialogTitlePattern: '{node} im Überblick',
|
||||
closeLabel: 'Kurzprofil schließen',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
sectionLinkPattern: 'Zum Bereich {node}',
|
||||
caseLinkPattern: 'Zum Fall {node}',
|
||||
nodeLinkHint:
|
||||
'Jeder Knoten bleibt ein normaler Link — Mittelklick oder Klick mit gedrückter Zusatztaste öffnet das Ziel direkt in einem neuen Tab.',
|
||||
legend: {
|
||||
ai: 'KI-Integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware und Netz',
|
||||
software: 'Software',
|
||||
project: 'Projektfeld',
|
||||
infrastructure: 'Server und Netz',
|
||||
platform: 'Plattform und Betrieb',
|
||||
software: 'Software und Daten',
|
||||
ai: 'KI-Abläufe',
|
||||
project: 'Öffentlicher Fall',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
palette: {
|
||||
triggerLabel: 'Open commands',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
triggerAccessibleName: 'Open the terminal',
|
||||
triggerTooltip: 'Open the terminal (Ctrl+K)',
|
||||
triggerTooltipApple: 'Open the terminal (⌘K)',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Commands',
|
||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
closeLabel: 'Close',
|
||||
suggestionsLabel: 'Suggestions',
|
||||
collapseLabel: 'Collapse the terminal',
|
||||
collapseTooltip: 'Collapse (Esc)',
|
||||
maximizeLabel: 'Maximise the terminal',
|
||||
maximizeTooltip: 'Maximise',
|
||||
restoreLabel: 'Restore the terminal',
|
||||
restoreTooltip: 'Restore the previous size',
|
||||
outputLabel: 'Output',
|
||||
emptySuggestions: 'No matching commands.',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
unknownTarget: 'Unknown target: {target}',
|
||||
validTargetsLabel: 'Valid targets:',
|
||||
helpIntro: 'Available commands:',
|
||||
helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate <target> [<section>]',
|
||||
helpTargetsIntro: 'Targets for navigate:',
|
||||
historyEmpty: 'No commands have been entered in this session.',
|
||||
historyIntro: 'Entered commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
jumping: 'Jumping to {target}.',
|
||||
incompleteHint: 'Incomplete command. Possible targets:',
|
||||
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.',
|
||||
help: 'Lists the grammar and every command.',
|
||||
history: 'Lists the commands entered in this session.',
|
||||
clear: 'Clears the output log.',
|
||||
brew: 'Pours a coffee. Nothing else happens.',
|
||||
rev: 'Blips the throttle. Nothing else happens.',
|
||||
navigate: 'Goes to a page, jumps to a section of the services page, or opens the CV.',
|
||||
},
|
||||
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',
|
||||
intro: 'How the four areas connect, and which public case sits under each of them.',
|
||||
diagramDescription:
|
||||
'Diagram of the four service areas, the public cases and the connections between them.',
|
||||
listHeading: 'The same map as a list',
|
||||
legendHeading: 'Legend',
|
||||
relationshipLabel: 'Connected to {targets}.',
|
||||
dialogTitlePattern: '{node} — in short',
|
||||
closeLabel: 'Close the details',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
sectionLinkPattern: 'Open the full section: {node}',
|
||||
caseLinkPattern: 'Open the case: {node}',
|
||||
nodeLinkHint:
|
||||
'Every node is a normal link as well — middle-click or modifier-click opens it straight in a new tab.',
|
||||
legend: {
|
||||
ai: 'AI integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware and network',
|
||||
software: 'Software',
|
||||
project: 'Project field',
|
||||
infrastructure: 'Servers and networks',
|
||||
platform: 'Platform and operations',
|
||||
software: 'Software and data',
|
||||
ai: 'AI workflows',
|
||||
project: 'Public case',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { PRIMARY_NAV } from './navigation';
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
|
||||
expect(navigation.link('home')).toEqual(['/', 'en']);
|
||||
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
|
||||
});
|
||||
|
||||
it('keeps primary nav to home, services, projects, about and contact', () => {
|
||||
const ids = PRIMARY_NAV.map((item) => item.routeId);
|
||||
expect(ids).toEqual(['home', 'services', 'projects', 'about', 'contact']);
|
||||
expect(ids).not.toContain('stack');
|
||||
expect(ids).not.toContain('pitch');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface ResolvedNavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: string;
|
||||
readonly link: unknown[];
|
||||
readonly children?: readonly ResolvedNavItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -56,7 +55,6 @@ export class NavigationService {
|
||||
routeId: item.routeId,
|
||||
label: item.label[locale],
|
||||
link: routeCommands(item.routeId, locale),
|
||||
children: item.children ? this.resolveItems(item.children, locale) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { type RouteId } from '../routing/route-ids';
|
||||
export interface NavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
readonly children?: readonly NavItem[];
|
||||
}
|
||||
|
||||
export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
@@ -15,33 +14,11 @@ export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
{
|
||||
routeId: 'services',
|
||||
label: { de: 'Leistungen', en: 'Services' },
|
||||
children: [
|
||||
{
|
||||
routeId: 'servicesSoftware',
|
||||
label: { de: 'Software', en: 'Software' },
|
||||
},
|
||||
{
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
label: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
|
||||
},
|
||||
{
|
||||
routeId: 'servicesClusters',
|
||||
label: { de: 'Cluster', en: 'Clusters' },
|
||||
},
|
||||
{
|
||||
routeId: 'servicesAi',
|
||||
label: { de: 'KI-Integration', en: 'AI integration' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: 'projects',
|
||||
label: { de: 'Projekte', en: 'Projects' },
|
||||
},
|
||||
{
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
},
|
||||
{
|
||||
routeId: 'about',
|
||||
label: { de: 'Über mich', en: 'About' },
|
||||
|
||||
@@ -19,7 +19,7 @@ export function viewportMatches(query: string): boolean {
|
||||
|
||||
/**
|
||||
* Keyboard-labelling exception: there is no CSS media query for the Command key.
|
||||
* Used only to swap the palette shortcut hint after hydration.
|
||||
* Used only to swap the terminal shortcut hint after hydration.
|
||||
*/
|
||||
export function isApplePlatform(): boolean {
|
||||
if (!isBrowserPlatform()) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export type RouteId =
|
||||
| 'home'
|
||||
| 'pitch'
|
||||
| 'services'
|
||||
| 'servicesSoftware'
|
||||
| 'servicesHardwareNetwork'
|
||||
| 'servicesClusters'
|
||||
| 'servicesAi'
|
||||
| 'projects'
|
||||
| 'stack'
|
||||
| 'about'
|
||||
@@ -15,11 +12,8 @@ export type RouteId =
|
||||
|
||||
export const ROUTE_IDS: readonly RouteId[] = [
|
||||
'home',
|
||||
'pitch',
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
'servicesClusters',
|
||||
'servicesAi',
|
||||
'projects',
|
||||
'stack',
|
||||
'about',
|
||||
|
||||
@@ -27,6 +27,14 @@ describe('route paths', () => {
|
||||
expect(routePath('projects', 'en')).toBe('/en/projects');
|
||||
});
|
||||
|
||||
it('exposes pitch in both locales and keeps prerenderablePaths at 18', () => {
|
||||
expect(ROUTE_SEGMENTS.de.pitch).toBe('pitch');
|
||||
expect(ROUTE_SEGMENTS.en.pitch).toBe('pitch');
|
||||
expect(routePath('pitch', 'de')).toBe('/pitch');
|
||||
expect(routePath('pitch', 'en')).toBe('/en/pitch');
|
||||
expect(prerenderablePaths()).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
|
||||
const paths = prerenderablePaths();
|
||||
|
||||
|
||||
@@ -9,11 +9,8 @@ export const LOCALE_PREFIX: Record<AppLocale, string> = {
|
||||
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
de: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'leistungen',
|
||||
servicesSoftware: 'leistungen/software',
|
||||
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
|
||||
servicesClusters: 'leistungen/cluster',
|
||||
servicesAi: 'leistungen/ai-integration',
|
||||
projects: 'projekte',
|
||||
stack: 'stack',
|
||||
about: 'ueber-mich',
|
||||
@@ -24,11 +21,8 @@ export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
},
|
||||
en: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'services',
|
||||
servicesSoftware: 'services/software',
|
||||
servicesHardwareNetwork: 'services/hardware-network',
|
||||
servicesClusters: 'services/clusters',
|
||||
servicesAi: 'services/ai-integration',
|
||||
projects: 'projects',
|
||||
stack: 'stack',
|
||||
about: 'about',
|
||||
|
||||
@@ -113,6 +113,8 @@ describe('crawl assets', () => {
|
||||
const required = [
|
||||
absoluteUrl(routePath('home', 'de')),
|
||||
absoluteUrl(routePath('home', 'en')),
|
||||
absoluteUrl(routePath('pitch', 'de')),
|
||||
absoluteUrl(routePath('pitch', 'en')),
|
||||
absoluteUrl(routePath('about', 'de')),
|
||||
absoluteUrl(routePath('about', 'en')),
|
||||
absoluteUrl(routePath('services', 'de')),
|
||||
|
||||
@@ -58,4 +58,28 @@ describe('SeoService', () => {
|
||||
);
|
||||
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('writes pitch metadata, canonical and reciprocal hreflang', () => {
|
||||
const seo = TestBed.inject(SeoService);
|
||||
seo.apply('pitch', 'de');
|
||||
|
||||
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
|
||||
`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`,
|
||||
);
|
||||
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
|
||||
'index, follow',
|
||||
);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="de-DE"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'en')}`);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="x-default"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
|
||||
expect(document.querySelector('script[type="application/ld+json"]')?.textContent).toContain(
|
||||
'WebPage',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('JSON-LD builders', () => {
|
||||
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
|
||||
expect(home['@context']).toBe('https://schema.org');
|
||||
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
|
||||
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
|
||||
|
||||
for (const routeId of SERVICE_ROUTES) {
|
||||
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);
|
||||
|
||||
@@ -6,13 +6,7 @@ import { canonicalUrl } from './route-metadata';
|
||||
|
||||
const SCHEMA_CONTEXT = 'https://schema.org';
|
||||
|
||||
const SERVICE_ROUTE_IDS: readonly RouteId[] = [
|
||||
'services',
|
||||
'servicesSoftware',
|
||||
'servicesHardwareNetwork',
|
||||
'servicesClusters',
|
||||
'servicesAi',
|
||||
];
|
||||
const SERVICE_ROUTE_IDS: readonly RouteId[] = ['services'];
|
||||
|
||||
export type JsonLdValue =
|
||||
| string
|
||||
@@ -56,23 +50,23 @@ function professionalServiceNode(
|
||||
};
|
||||
}
|
||||
|
||||
function serviceNode(
|
||||
routeId: RouteId,
|
||||
function serviceNodes(
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): JsonLdValue {
|
||||
const page = content[locale].pages[routeId];
|
||||
): readonly JsonLdValue[] {
|
||||
const page = content[locale].services;
|
||||
const servicesUrl = canonicalUrl('services', locale);
|
||||
|
||||
return {
|
||||
return page.serviceSections.map((section) => ({
|
||||
'@type': 'Service',
|
||||
name: page.hero.headline,
|
||||
description: page.description,
|
||||
url: canonicalUrl(routeId, locale),
|
||||
name: section.title,
|
||||
description: section.lead,
|
||||
url: `${servicesUrl}#${section.id}`,
|
||||
provider: {
|
||||
'@type': 'Person',
|
||||
name: SITE_CONFIG.personName,
|
||||
},
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function projectWorks(
|
||||
@@ -128,7 +122,7 @@ export function buildJsonLdGraph(
|
||||
if (routeId === 'home') {
|
||||
graph = [personNode(locale, content), professionalServiceNode(locale, content)];
|
||||
} else if (SERVICE_ROUTE_IDS.includes(routeId)) {
|
||||
graph = [serviceNode(routeId, locale, content)];
|
||||
graph = serviceNodes(locale, content);
|
||||
} else if (routeId === 'projects') {
|
||||
graph = projectWorks(locale, content);
|
||||
} else {
|
||||
|
||||
@@ -3,40 +3,40 @@
|
||||
<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'" />
|
||||
<div appReveal class="home-reveal">
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
@if (section.id === 'areas') {
|
||||
<section class="stack" [attr.aria-labelledby]="'home-service-areas'">
|
||||
<h2 id="home-service-areas">{{ copy.serviceAreasHeading }}</h2>
|
||||
<ul class="home-service-areas">
|
||||
@for (area of copy.serviceAreas; track area.id) {
|
||||
<li>
|
||||
<a
|
||||
class="home-service-card interactive-lift stack"
|
||||
[routerLink]="areaLink(area)"
|
||||
[fragment]="area.fragment"
|
||||
>
|
||||
<h3>{{ area.title }}</h3>
|
||||
<p>{{ area.body }}</p>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="stack" [attr.aria-labelledby]="'home-process'">
|
||||
<h2 id="home-process">{{ copy.processHeading }}</h2>
|
||||
<app-process-steps [steps]="copy.process" [labelledBy]="'home-process'" />
|
||||
</section>
|
||||
}
|
||||
@if (section.id === 'featured-cases') {
|
||||
@if (section.id === 'proof') {
|
||||
<div appReveal class="home-reveal stack">
|
||||
<h2 id="home-proof">{{ copy.proofHeading }}</h2>
|
||||
<app-case-card [caseStudy]="proofCase()" />
|
||||
<p class="home-proof-note" role="note">{{ copy.proofNote }}</p>
|
||||
@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>
|
||||
}
|
||||
<app-cta-row [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,8 +11,51 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page p,
|
||||
.page li {
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.home-service-areas {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-service-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.home-service-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.home-proof-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.home-service-card:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-service-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { type HomeServiceAreaCopy } from '../../core/content/content.contracts';
|
||||
import { NavigationService } from '../../core/navigation/navigation.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 { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { RevealDirective } from '../../shared/motion/reveal.directive';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
import { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
import { ProcessSteps } from '../../shared/process-steps/process-steps';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective],
|
||||
imports: [PageHero, ContentSection, ProcessSteps, CaseCard, CtaRow, RevealDirective, RouterLink],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
export class HomePage {
|
||||
private readonly content = inject(ContentService);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected readonly page = this.content.home();
|
||||
protected readonly proofCase = computed(() => this.content.caseStudy(this.page().proofCaseId)());
|
||||
protected readonly featuredCases = computed(() => {
|
||||
const ids = this.page().featuredCaseIds;
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
|
||||
protected areaLink(area: HomeServiceAreaCopy): unknown[] {
|
||||
return this.navigation.link(area.routeId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -67,6 +66,8 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
const paths = [
|
||||
'/',
|
||||
'/en',
|
||||
'/pitch',
|
||||
'/en/pitch',
|
||||
'/leistungen/software',
|
||||
'/en/services/software',
|
||||
'/projekte',
|
||||
@@ -111,7 +112,7 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes case anchors, audience entries and the CV download', async () => {
|
||||
it('exposes case anchors and the Rösterei proof on Home', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
@@ -129,37 +130,69 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
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(root.querySelector('#home-proof')).toBeTruthy();
|
||||
expect(root.querySelector('app-case-card')).toBeTruthy();
|
||||
expect(root.querySelector('#recruiters')).toBeNull();
|
||||
expect(root.querySelector('#companies')).toBeNull();
|
||||
}
|
||||
|
||||
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('places the Systems Map between the profile block and the featured cases', async () => {
|
||||
it('places the Systems Map on the services overview', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/leistungen', '/en/services']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const map = root.querySelector('app-systems-map');
|
||||
expect(map).toBeTruthy();
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
|
||||
for (const path of ['/', '/en']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const metrics = root.querySelector('app-metric-list');
|
||||
const map = root.querySelector('app-systems-map');
|
||||
const firstCase = root.querySelector('app-case-card');
|
||||
expect(root.querySelector('app-systems-map')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(map).toBeTruthy();
|
||||
expect(firstCase).toBeTruthy();
|
||||
it('renders pitch as profile, stations, stack, then cases', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const position = metrics!.compareDocumentPosition(map!);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const afterMap = map!.compareDocumentPosition(firstCase!);
|
||||
expect(afterMap & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/pitch', '/en/pitch']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const headingIds = [...root.querySelectorAll('h2')].map((heading) => heading.id);
|
||||
expect(headingIds, path).toEqual([
|
||||
'section-profile',
|
||||
'pitch-timeline',
|
||||
'pitch-stack',
|
||||
'section-featured-cases',
|
||||
]);
|
||||
expect(root.querySelectorAll('app-case-card')).toHaveLength(CASE_STUDY_IDS.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders pitch in both locales with a single h1', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/pitch', '/en/pitch']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root.querySelectorAll('h1')).toHaveLength(1);
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
});
|
||||
|
||||
54
src/app/features/pitch/pitch.html
Normal file
54
src/app/features/pitch/pitch.html
Normal file
@@ -0,0 +1,54 @@
|
||||
@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) {
|
||||
@if (section.id === 'profile') {
|
||||
<app-content-section [section]="section" />
|
||||
<ul class="stack">
|
||||
@for (line of copy.profile; track $index) {
|
||||
<li>{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
||||
<section class="stack" [attr.aria-labelledby]="'pitch-timeline'">
|
||||
<h2 id="pitch-timeline">{{ copy.timelineHeading }}</h2>
|
||||
<ol class="stack">
|
||||
@for (entry of copy.timeline; track entry.id) {
|
||||
<li>
|
||||
<p>
|
||||
<strong>{{ entry.period }}</strong>
|
||||
· {{ entry.role }}
|
||||
</p>
|
||||
<p>{{ entry.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
<section class="stack" [attr.aria-labelledby]="'pitch-stack'">
|
||||
<h2 id="pitch-stack">{{ copy.stackHeading }}</h2>
|
||||
<ul class="stack">
|
||||
@for (group of copy.coreStack; track group.id) {
|
||||
<li>
|
||||
<p>
|
||||
<strong>{{ group.title }}</strong>
|
||||
@if (group.body) {
|
||||
— {{ group.body }}
|
||||
}
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@if (section.id === 'featured-cases') {
|
||||
<app-content-section [section]="section" />
|
||||
<div class="stack">
|
||||
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
||||
<app-case-card [caseStudy]="caseStudy" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<app-cta-row [ctas]="copy.ctas" />
|
||||
</div>
|
||||
}
|
||||
24
src/app/features/pitch/pitch.ts
Normal file
24
src/app/features/pitch/pitch.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
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 { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pitch-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
|
||||
templateUrl: './pitch.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class PitchPage {
|
||||
private readonly content = inject(ContentService);
|
||||
|
||||
protected readonly page = this.content.pitch();
|
||||
protected readonly featuredCases = computed(() => {
|
||||
const ids = this.page().featuredCaseIds;
|
||||
return ids.map((id) => this.content.caseStudy(id)());
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesAi" />
|
||||
@@ -1,10 +0,0 @@
|
||||
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: [ServicePageView],
|
||||
templateUrl: './ai-integration.html',
|
||||
})
|
||||
export class ServicesAiPage {}
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesClusters" />
|
||||
@@ -1,10 +0,0 @@
|
||||
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: [ServicePageView],
|
||||
templateUrl: './clusters.html',
|
||||
})
|
||||
export class ServicesClustersPage {}
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesHardwareNetwork" />
|
||||
@@ -1,10 +0,0 @@
|
||||
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: [ServicePageView],
|
||||
templateUrl: './hardware-network.html',
|
||||
})
|
||||
export class ServicesHardwareNetworkPage {}
|
||||
59
src/app/features/services/service-offerings.spec.ts
Normal file
59
src/app/features/services/service-offerings.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { APP_LOCALES } from '../../core/i18n/locale';
|
||||
import { routePath } from '../../core/routing/route-paths';
|
||||
|
||||
describe('service offering groups', () => {
|
||||
it('renders delivered work and offers under their own localized labels', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const page = SITE_CONTENT_DATA[locale].services;
|
||||
await harness.navigateByUrl(routePath('services', locale));
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
for (const section of page.serviceSections) {
|
||||
const delivered = section.offerings.filter((o) => o.status === 'delivered');
|
||||
const offers = section.offerings.filter((o) => o.status === 'offer');
|
||||
expect(delivered.length, `${locale}.${section.id} delivered`).toBeGreaterThan(0);
|
||||
expect(offers.length, `${locale}.${section.id} offers`).toBeGreaterThan(0);
|
||||
|
||||
const deliveredGroup = root.querySelector(
|
||||
`[aria-labelledby="offerings-delivered-${section.id}"]`,
|
||||
);
|
||||
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-${section.id}"]`);
|
||||
expect(deliveredGroup, `${locale}.${section.id}`).toBeTruthy();
|
||||
expect(offerGroup, `${locale}.${section.id}`).toBeTruthy();
|
||||
expect(deliveredGroup?.textContent).toContain(page.deliveredOfferingsHeading);
|
||||
expect(offerGroup?.textContent).toContain(page.offerOfferingsHeading);
|
||||
|
||||
for (const offering of delivered) {
|
||||
expect(deliveredGroup?.textContent).toContain(offering.title);
|
||||
expect(offerGroup?.textContent).not.toContain(offering.title);
|
||||
const note = page.offeringCaseBackedLabel.replace(
|
||||
'{case}',
|
||||
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
|
||||
);
|
||||
expect(deliveredGroup?.textContent).toContain(note);
|
||||
}
|
||||
|
||||
for (const offering of offers) {
|
||||
expect(offerGroup?.textContent).toContain(offering.title);
|
||||
expect(deliveredGroup?.textContent).not.toContain(offering.title);
|
||||
}
|
||||
|
||||
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
|
||||
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
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 { APP_LOCALES } from '../../../core/i18n/locale';
|
||||
import { routePath } from '../../../core/routing/route-paths';
|
||||
|
||||
describe('AI service offering groups', () => {
|
||||
it('renders delivered work and offers under their own localized labels', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
const copy = SITE_CONTENT_DATA[locale].services.servicesAi;
|
||||
const delivered = copy.offerings.filter((offering) => offering.status === 'delivered');
|
||||
const offers = copy.offerings.filter((offering) => offering.status === 'offer');
|
||||
expect(delivered.length).toBeGreaterThan(0);
|
||||
expect(offers.length).toBeGreaterThan(0);
|
||||
|
||||
await harness.navigateByUrl(routePath('servicesAi', locale));
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
expect(root).toBeTruthy();
|
||||
|
||||
const deliveredGroup = root.querySelector(
|
||||
`[aria-labelledby="offerings-delivered-servicesAi"]`,
|
||||
);
|
||||
const offerGroup = root.querySelector(`[aria-labelledby="offerings-offer-servicesAi"]`);
|
||||
expect(deliveredGroup).toBeTruthy();
|
||||
expect(offerGroup).toBeTruthy();
|
||||
expect(deliveredGroup?.textContent).toContain(copy.deliveredOfferingsHeading);
|
||||
expect(offerGroup?.textContent).toContain(copy.offerOfferingsHeading);
|
||||
|
||||
for (const offering of delivered) {
|
||||
expect(deliveredGroup?.textContent).toContain(offering.title);
|
||||
expect(offerGroup?.textContent).not.toContain(offering.title);
|
||||
const note = copy.offeringCaseBackedLabel.replace(
|
||||
'{case}',
|
||||
SITE_CONTENT_DATA[locale].cases[offering.referenceCaseId!].client,
|
||||
);
|
||||
expect(deliveredGroup?.textContent).toContain(note);
|
||||
}
|
||||
|
||||
for (const offering of offers) {
|
||||
expect(offerGroup?.textContent).toContain(offering.title);
|
||||
expect(deliveredGroup?.textContent).not.toContain(offering.title);
|
||||
}
|
||||
|
||||
expect(deliveredGroup?.querySelector('[data-offering-status="offer"]')).toBeNull();
|
||||
expect(offerGroup?.querySelector('[data-offering-status="delivered"]')).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
@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 === 'scope' && deliveredOfferings().length > 0) {
|
||||
<section class="stack offerings-group" [attr.aria-labelledby]="deliveredHeadingId()">
|
||||
<h3 [id]="deliveredHeadingId()">{{ copy.deliveredOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of deliveredOfferings(); track offering.id) {
|
||||
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
@if (caseBackedNote(offering); as note) {
|
||||
<p class="case-backed">{{ note }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@if (section.id === 'offer-boundary' && offerOfferings().length > 0) {
|
||||
<section class="stack offerings-group" [attr.aria-labelledby]="offerHeadingId()">
|
||||
<h3 [id]="offerHeadingId()">{{ copy.offerOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of offerOfferings(); track offering.id) {
|
||||
<li class="glass-surface" [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
<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>
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
@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-group h3,
|
||||
.offerings h4 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.offerings h4 {
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
|
||||
.offerings p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.offerings .case-backed {
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
|
||||
import {
|
||||
type OfferingCopy,
|
||||
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;
|
||||
});
|
||||
protected readonly deliveredOfferings = computed(() =>
|
||||
this.page().offerings.filter((offering) => offering.status === 'delivered'),
|
||||
);
|
||||
protected readonly offerOfferings = computed(() =>
|
||||
this.page().offerings.filter((offering) => offering.status === 'offer'),
|
||||
);
|
||||
protected readonly deliveredHeadingId = computed(() => `offerings-delivered-${this.routeId()}`);
|
||||
protected readonly offerHeadingId = computed(() => `offerings-offer-${this.routeId()}`);
|
||||
|
||||
protected caseBackedNote(offering: OfferingCopy): string | null {
|
||||
const caseId = offering.referenceCaseId;
|
||||
if (!caseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.page().offeringCaseBackedLabel.replace(
|
||||
'{case}',
|
||||
this.content.caseStudy(caseId)().client,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,133 @@
|
||||
@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-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
|
||||
<section class="stack service-areas" [attr.aria-labelledby]="'service-areas-heading'">
|
||||
<h2 id="service-areas-heading">{{ copy.sectionsHeading }}</h2>
|
||||
|
||||
@for (section of copy.serviceSections; track section.id) {
|
||||
<article class="service-section stack" [attr.aria-labelledby]="section.id">
|
||||
<header class="service-section-header stack">
|
||||
<h2 [id]="section.id">{{ section.title }}</h2>
|
||||
<p class="service-section-lead">{{ section.lead }}</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.situation.id"
|
||||
[attr.aria-labelledby]="section.situation.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.situation.id + '-heading'">{{ section.situation.headline }}</h3>
|
||||
@for (paragraph of section.situation.body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.diagnosis.id"
|
||||
[attr.aria-labelledby]="section.diagnosis.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.diagnosis.id + '-heading'">{{ section.diagnosis.headline }}</h3>
|
||||
@for (paragraph of section.diagnosis.body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.implementation.id"
|
||||
[attr.aria-labelledby]="section.implementation.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.implementation.id + '-heading'">
|
||||
{{ section.implementation.headline }}
|
||||
</h3>
|
||||
@for (paragraph of section.implementation.body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.operation.id"
|
||||
[attr.aria-labelledby]="section.operation.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.operation.id + '-heading'">{{ section.operation.headline }}</h3>
|
||||
@for (paragraph of section.operation.body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack offerings-group"
|
||||
[attr.aria-labelledby]="deliveredHeadingId(section.id)"
|
||||
>
|
||||
<h3 [id]="deliveredHeadingId(section.id)">{{ copy.deliveredOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of deliveredOfferings(section); track offering.id) {
|
||||
<li [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
@if (caseBackedNote(offering); as note) {
|
||||
<p class="case-backed">{{ note }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack offerings-group"
|
||||
[attr.aria-labelledby]="offerHeadingId(section.id)"
|
||||
>
|
||||
<h3 [id]="offerHeadingId(section.id)">{{ copy.offerOfferingsHeading }}</h3>
|
||||
<ul class="stack offerings">
|
||||
@for (offering of offerOfferings(section); track offering.id) {
|
||||
<li [attr.data-offering-status]="offering.status">
|
||||
<h4>{{ offering.title }}</h4>
|
||||
<p>{{ offering.body }}</p>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.evidence.id"
|
||||
[attr.aria-labelledby]="section.evidence.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.evidence.id + '-heading'">{{ section.evidence.heading }}</h3>
|
||||
<p>{{ section.evidence.note }}</p>
|
||||
<div class="evidence-cases stack">
|
||||
@for (study of evidenceCases(section); track study.id) {
|
||||
<app-case-card [caseStudy]="study" [idPrefix]="'evidence-' + section.id" />
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="stack service-part"
|
||||
[id]="section.inquiry.id"
|
||||
[attr.aria-labelledby]="section.inquiry.id + '-heading'"
|
||||
>
|
||||
<h3 [id]="section.inquiry.id + '-heading'">{{ section.inquiry.headline }}</h3>
|
||||
@for (paragraph of section.inquiry.body ?? []; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
<app-cta-row [ctas]="copy.ctas.slice(-2)" />
|
||||
</section>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
66
src/app/features/services/services.scss
Normal file
66
src/app/features/services/services.scss
Normal file
@@ -0,0 +1,66 @@
|
||||
@use '../../shared/page-shell';
|
||||
|
||||
.service-areas {
|
||||
gap: var(--space-10);
|
||||
}
|
||||
|
||||
.service-section {
|
||||
padding-block: var(--space-6);
|
||||
border-inline-start: 3px solid var(--color-accent);
|
||||
padding-inline-start: var(--space-5);
|
||||
scroll-margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.service-section-header h2,
|
||||
.service-part h3,
|
||||
.offerings-group h3,
|
||||
.offerings h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.service-section-header h2 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.service-section-lead,
|
||||
.service-part p,
|
||||
.offerings p {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.service-part,
|
||||
.offerings-group {
|
||||
scroll-margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.offerings {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.offerings li {
|
||||
padding-block: var(--space-3);
|
||||
border-block-end: 1px solid var(--surface-glass-border);
|
||||
}
|
||||
|
||||
.offerings li:last-child {
|
||||
border-block-end: 0;
|
||||
}
|
||||
|
||||
.offerings h4 {
|
||||
font-size: var(--text-md);
|
||||
margin-block-end: var(--space-2);
|
||||
}
|
||||
|
||||
.offerings .case-backed {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.evidence-cases {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -1,15 +1,55 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { type OfferingCopy, type ServiceSectionCopy } 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 { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-services-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection],
|
||||
imports: [PageHero, ContentSection, SystemsMap, CaseCard, CtaRow],
|
||||
templateUrl: './services.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
styleUrl: './services.scss',
|
||||
})
|
||||
export class ServicesPage {
|
||||
protected readonly page = inject(ContentService).page('services');
|
||||
private readonly content = inject(ContentService);
|
||||
|
||||
protected readonly page = this.content.services();
|
||||
|
||||
protected deliveredOfferings(section: ServiceSectionCopy): readonly OfferingCopy[] {
|
||||
return section.offerings.filter((offering) => offering.status === 'delivered');
|
||||
}
|
||||
|
||||
protected offerOfferings(section: ServiceSectionCopy): readonly OfferingCopy[] {
|
||||
return section.offerings.filter((offering) => offering.status === 'offer');
|
||||
}
|
||||
|
||||
protected deliveredHeadingId(sectionId: string): string {
|
||||
return `offerings-delivered-${sectionId}`;
|
||||
}
|
||||
|
||||
protected offerHeadingId(sectionId: string): string {
|
||||
return `offerings-offer-${sectionId}`;
|
||||
}
|
||||
|
||||
protected caseBackedNote(offering: OfferingCopy): string | null {
|
||||
const caseId = offering.referenceCaseId;
|
||||
if (!caseId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const study = this.content.caseStudy(caseId)();
|
||||
if (!study) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.page().offeringCaseBackedLabel.replace('{case}', study.client);
|
||||
}
|
||||
|
||||
protected evidenceCases(section: ServiceSectionCopy) {
|
||||
return section.evidence.caseIds.map((id) => this.content.caseStudy(id)());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<app-service-page-view routeId="servicesSoftware" />
|
||||
@@ -1,10 +0,0 @@
|
||||
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: [ServicePageView],
|
||||
templateUrl: './software.html',
|
||||
})
|
||||
export class ServicesSoftwarePage {}
|
||||
@@ -1,14 +1,11 @@
|
||||
<article
|
||||
class="case-card glass-surface stack"
|
||||
[attr.aria-labelledby]="'case-card-' + caseStudy().id"
|
||||
<a
|
||||
class="case-card interactive-lift stack"
|
||||
[routerLink]="projectsLink()"
|
||||
[fragment]="caseStudy().id"
|
||||
[attr.aria-labelledby]="headingId()"
|
||||
>
|
||||
<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>
|
||||
<h3 [id]="headingId()">{{ caseStudy().client }}</h3>
|
||||
<p class="meta">{{ caseStudy().role }} · {{ caseStudy().period }}</p>
|
||||
<p class="teaser">{{ caseStudy().teaser }}</p>
|
||||
<span class="case-card-link">{{ caseStudy().linkLabel }}</span>
|
||||
</a>
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
.case-card {
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-md);
|
||||
height: 100%;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
border-inline-start: 3px solid var(--color-accent);
|
||||
background: var(--color-surface-raised);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta,
|
||||
p {
|
||||
.teaser {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tags {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--color-text-subtle);
|
||||
.teaser {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.case-card-link {
|
||||
color: var(--color-accent-cool);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
})
|
||||
export class CaseCard {
|
||||
readonly caseStudy = input.required<CaseStudyCopy>();
|
||||
readonly idPrefix = input('case-card');
|
||||
|
||||
private readonly navigation = inject(NavigationService);
|
||||
|
||||
protected readonly projectsLink = computed(() => this.navigation.link('projects'));
|
||||
protected readonly headingId = computed(() => `${this.idPrefix()}-${this.caseStudy().id}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
@if (caseStudy().transparencyNote; as note) {
|
||||
<p class="transparency" role="note">{{ note }}</p>
|
||||
}
|
||||
@if (caseStudy().externalUrl; as url) {
|
||||
<p>
|
||||
<a class="external-link" [href]="url" rel="noopener noreferrer" target="_blank">
|
||||
{{ caseStudy().externalLabel }}
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<section class="stack" [attr.aria-labelledby]="caseStudy().id + '-situation'">
|
||||
|
||||
@@ -48,3 +48,19 @@ ul {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.external-link {
|
||||
color: var(--color-accent-cool);
|
||||
}
|
||||
|
||||
.external-link::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
margin-inline-start: var(--space-1);
|
||||
border-block-end: 1px solid currentColor;
|
||||
border-inline-end: 1px solid currentColor;
|
||||
transform: translateY(-0.15em) rotate(-45deg);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="open() ? dialogId : null"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ shortcutHint() }}</kbd>
|
||||
</button>
|
||||
@@ -1,32 +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);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { SIGNATURE_COPY } from '../../../core/content/signature-copy';
|
||||
import { LocaleService } from '../../../core/i18n/locale.service';
|
||||
import { isApplePlatform, isBrowserPlatform } from '../../../core/platform/browser';
|
||||
import { CommandPaletteService } from '../command-palette.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-palette-trigger',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './command-palette-trigger.html',
|
||||
styleUrl: './command-palette-trigger.scss',
|
||||
})
|
||||
export class CommandPaletteTrigger {
|
||||
private readonly palette = inject(CommandPaletteService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly applePlatform = isApplePlatform();
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
protected readonly useAppleHint = signal(false);
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||
protected readonly shortcutHint = computed(() =>
|
||||
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
|
||||
);
|
||||
protected readonly open = this.palette.open;
|
||||
protected readonly dialogId = this.palette.dialogId;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (this.isBrowser) {
|
||||
this.useAppleHint.set(this.applePlatform);
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
const trigger = this.triggerRef()?.nativeElement;
|
||||
trigger?.focus();
|
||||
this.palette.openPalette(trigger ?? null);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
@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,144 +0,0 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.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-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,115 +0,0 @@
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CommandPaletteService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
readonly open = signal(false);
|
||||
readonly dialogId = 'command-palette-dialog';
|
||||
readonly titleId = 'command-palette-title';
|
||||
readonly descriptionId = 'command-palette-description';
|
||||
readonly inputId = 'command-palette-input';
|
||||
readonly suggestionsId = 'command-palette-suggestions';
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
private previousOverflow = '';
|
||||
private scrollLocked = false;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
}
|
||||
|
||||
openPalette(opener?: HTMLElement | null): void {
|
||||
if (this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active = opener ?? this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.lockScroll();
|
||||
this.open.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.unlockScroll();
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private lockScroll(): void {
|
||||
if (!this.isBrowser || this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousOverflow = this.document.body.style.overflow;
|
||||
this.document.body.style.overflow = 'hidden';
|
||||
this.scrollLocked = true;
|
||||
}
|
||||
|
||||
private unlockScroll(): void {
|
||||
if (!this.isBrowser || !this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.body.style.overflow = this.previousOverflow;
|
||||
this.previousOverflow = '';
|
||||
this.scrollLocked = false;
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
this.unlockScroll();
|
||||
this.open.set(false);
|
||||
this.opener = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, Component, inject, 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 { CommandPaletteTrigger } from './command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './command-palette.service';
|
||||
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||
|
||||
@Component({
|
||||
selector: 'app-palette-host',
|
||||
imports: [CommandPaletteTrigger, CommandPalette],
|
||||
template: `
|
||||
<div class="site" [attr.inert]="palette.open() ? '' : null">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
`,
|
||||
})
|
||||
class PaletteHost {
|
||||
readonly palette = inject(CommandPaletteService);
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<PaletteHost>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PaletteHost],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(PaletteHost);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<PaletteHost>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||
}
|
||||
|
||||
function dialog(fixture: ComponentFixture<PaletteHost>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||
}
|
||||
|
||||
function site(fixture: ComponentFixture<PaletteHost>): HTMLElement {
|
||||
return fixture.nativeElement.querySelector('.site');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(fixture: ComponentFixture<PaletteHost>, 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<PaletteHost>): 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)(
|
||||
'marks .site inert while open and removes it on close (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(true);
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'locks body overflow while open and restores it on close (%s)',
|
||||
async (locale) => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
);
|
||||
|
||||
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'));
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
|
||||
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();
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('keeps the trigger aria-label stable and shows the Ctrl hint before hydration', async () => {
|
||||
const fixture = await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
const button = trigger(fixture);
|
||||
expect(button.getAttribute('aria-label')).toBe(SIGNATURE_COPY.de.palette.triggerLabel);
|
||||
expect(button.querySelector('kbd')?.textContent).toBe(SIGNATURE_COPY.de.palette.shortcutHint);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
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 { CommandPaletteService } from './command-palette.service';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandDefinition,
|
||||
} from './commands';
|
||||
|
||||
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 router = inject(Router);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly palette = inject(CommandPaletteService);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||
|
||||
protected readonly dialogId = this.palette.dialogId;
|
||||
protected readonly titleId = this.palette.titleId;
|
||||
protected readonly descriptionId = this.palette.descriptionId;
|
||||
protected readonly inputId = this.palette.inputId;
|
||||
protected readonly suggestionsId = this.palette.suggestionsId;
|
||||
protected readonly open = this.palette.open;
|
||||
|
||||
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()),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected closePalette(): void {
|
||||
this.palette.close();
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -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,145 +0,0 @@
|
||||
import { type CommandId } from '../../core/commands/command-ids';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids';
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -78,8 +78,5 @@
|
||||
</button>
|
||||
}
|
||||
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||
@if (showCalendar()) {
|
||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -46,12 +46,11 @@ describe('ContactBriefing', () => {
|
||||
expect(disabledSubmit?.textContent).toContain(copy.submitLabel);
|
||||
expect(root.querySelector('[aria-invalid="true"]')).toBeNull();
|
||||
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-projectType', 'software-data');
|
||||
setControl(root, 'contact-situation', special);
|
||||
setControl(root, 'contact-timeframe', 'Q3');
|
||||
fixture.detectChanges();
|
||||
@@ -79,7 +78,7 @@ describe('ContactBriefing', () => {
|
||||
expect(decodedBody).toContain('team@example.com');
|
||||
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||
const projectTypeLabel = projectType?.options?.find(
|
||||
(option) => option.value === 'software',
|
||||
(option) => option.value === 'software-data',
|
||||
)?.label;
|
||||
expect(projectTypeLabel).toBeTruthy();
|
||||
expect(decodedBody).toContain(`${projectType!.label}: ${projectTypeLabel}`);
|
||||
@@ -92,6 +91,51 @@ describe('ContactBriefing', () => {
|
||||
expect(xhrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats project type as optional and enables mailto with name, email and situation', async () => {
|
||||
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;
|
||||
const projectType = copy.fields.find((field) => field.id === 'projectType');
|
||||
expect(projectType?.required).toBe(false);
|
||||
expect(projectType?.options?.some((option) => option.value === 'unsure')).toBe(true);
|
||||
expect(
|
||||
SITE_CONTENT_DATA.en.contact.fields
|
||||
.find((field) => field.id === 'projectType')
|
||||
?.options?.some((option) => option.value === 'unsure'),
|
||||
).toBe(true);
|
||||
|
||||
const unsure = projectType?.options?.find((option) => option.value === 'unsure');
|
||||
expect(unsure?.label).toBe('Noch unklar');
|
||||
expect(
|
||||
SITE_CONTENT_DATA.en.contact.fields
|
||||
.find((field) => field.id === 'projectType')
|
||||
?.options?.find((option) => option.value === 'unsure')?.label,
|
||||
).toBe('Not sure yet');
|
||||
|
||||
setControl(root, 'contact-name', 'Ada');
|
||||
setControl(root, 'contact-email', 'ada@example.com');
|
||||
setControl(root, 'contact-situation', 'Need a shop.');
|
||||
fixture.detectChanges();
|
||||
|
||||
const submit = root.querySelector<HTMLAnchorElement>('a.submit');
|
||||
expect(submit?.getAttribute('href')?.startsWith('mailto:')).toBe(true);
|
||||
expect(submit?.getAttribute('href')).not.toContain(
|
||||
encodeURIComponent(`${projectType!.label}:`),
|
||||
);
|
||||
|
||||
setControl(root, 'contact-projectType', 'unsure');
|
||||
fixture.detectChanges();
|
||||
const withType = root.querySelector<HTMLAnchorElement>('a.submit')?.getAttribute('href') ?? '';
|
||||
expect(decodeURIComponent(withType)).toContain(`${projectType!.label}: ${unsure!.label}`);
|
||||
});
|
||||
|
||||
it('marks required fields invalid only after interaction', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContactBriefing],
|
||||
|
||||
@@ -27,7 +27,6 @@ export class ContactBriefing {
|
||||
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
||||
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
|
||||
protected readonly contactEmail = SITE_CONFIG.contactEmail;
|
||||
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
||||
protected readonly incompleteId = 'contact-incomplete';
|
||||
|
||||
protected readonly missingLabels = computed(() => {
|
||||
@@ -56,11 +55,6 @@ export class ContactBriefing {
|
||||
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 }));
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
@if (hero().playfulLine; as playfulLine) {
|
||||
<p class="playful">{{ playfulLine }}</p>
|
||||
}
|
||||
@if (hero().body; as body) {
|
||||
@for (paragraph of body; track $index) {
|
||||
<p>{{ paragraph }}</p>
|
||||
}
|
||||
}
|
||||
<app-cta-row [ctas]="ctas()" />
|
||||
</header>
|
||||
|
||||
@@ -27,3 +27,9 @@ h1 {
|
||||
font-size: var(--text-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.page-hero p:not(.proof):not(.playful) {
|
||||
margin: 0;
|
||||
max-width: 40rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,13 @@
|
||||
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
|
||||
}
|
||||
<p class="systems-map-intro">{{ introText() }}</p>
|
||||
<p class="systems-map-kicker">{{ copy().nodeLinkHint }}</p>
|
||||
|
||||
<div class="systems-map-figure">
|
||||
<div class="systems-map-figure" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 560"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="group"
|
||||
[attr.aria-labelledby]="svgTitleId"
|
||||
[attr.aria-describedby]="svgDescId"
|
||||
>
|
||||
<title [id]="svgTitleId">{{ headingText() }}</title>
|
||||
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
|
||||
@@ -26,25 +24,22 @@
|
||||
class="systems-map-edge"
|
||||
[class.is-connected]="isConnectedEdge(edge)"
|
||||
[class.is-dimmed]="isDimmedEdge(edge)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
@for (node of nodes(); track node.id) {
|
||||
<a
|
||||
[attr.href]="node.href"
|
||||
[attr.aria-label]="node.accessibleName"
|
||||
tabindex="-1"
|
||||
class="systems-map-node"
|
||||
[attr.data-kind]="node.kind"
|
||||
[class.is-connected]="isConnectedNode(node.id)"
|
||||
[class.is-dimmed]="isDimmedNode(node.id)"
|
||||
(click)="onNodeActivate($event, node)"
|
||||
(focusin)="onNodeEnter(node.id)"
|
||||
(focusout)="onNodeLeave(node.id)"
|
||||
(mouseenter)="onNodeEnter(node.id)"
|
||||
(mouseleave)="onNodeLeave(node.id)"
|
||||
>
|
||||
@if (node.kind === 'ai' || node.kind === 'cluster') {
|
||||
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
|
||||
@if (node.kind === 'ai' || node.kind === 'platform') {
|
||||
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" />
|
||||
} @else {
|
||||
<rect
|
||||
[attr.x]="node.x - node.shapeWidth / 2"
|
||||
@@ -52,10 +47,9 @@
|
||||
[attr.width]="node.shapeWidth"
|
||||
[attr.height]="node.shapeHeight"
|
||||
rx="8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
<text [attr.x]="node.x" [attr.y]="node.y" aria-hidden="true">
|
||||
<text [attr.x]="node.x" [attr.y]="node.y">
|
||||
@for (line of node.labelLines; track $index) {
|
||||
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
||||
{{ line }}
|
||||
@@ -72,7 +66,12 @@
|
||||
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
|
||||
@for (node of nodes(); track node.id) {
|
||||
<li>
|
||||
<a [routerLink]="node.link" [attr.aria-label]="node.accessibleName">
|
||||
<a
|
||||
[href]="node.href"
|
||||
[attr.aria-label]="node.accessibleName"
|
||||
(click)="onNodeActivate($event, node)"
|
||||
(keydown)="onNodeKeydown($event, node)"
|
||||
>
|
||||
<span class="systems-map-card-label">{{ node.label }}</span>
|
||||
<span class="systems-map-card-summary">{{ node.summary }}</span>
|
||||
<span class="systems-map-card-relation">{{ node.relationship }}</span>
|
||||
@@ -89,5 +88,32 @@
|
||||
}
|
||||
</ul>
|
||||
|
||||
<p class="systems-map-readout" aria-hidden="true">{{ activeReadout() }}</p>
|
||||
@if (openNode(); as node) {
|
||||
<div
|
||||
#gistDialog
|
||||
class="systems-map-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="dialogTitleId"
|
||||
[attr.aria-describedby]="dialogDescId"
|
||||
tabindex="-1"
|
||||
(keydown)="onDialogKeydown($event)"
|
||||
>
|
||||
<div class="systems-map-dialog-bar cluster">
|
||||
<h3 [id]="dialogTitleId">{{ dialogTitle() }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="systems-map-dialog-close"
|
||||
[attr.aria-label]="copy().closeLabel"
|
||||
[attr.title]="copy().closeTooltip"
|
||||
(click)="closeDialog()"
|
||||
>
|
||||
{{ copy().closeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="systems-map-dialog-gist" [id]="dialogDescId">{{ node.gist }}</p>
|
||||
<p class="systems-map-dialog-relation">{{ node.relationship }}</p>
|
||||
<a [routerLink]="node.link" [fragment]="node.fragment">{{ node.linkLabel }}</a>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
import { routePath } from '../../core/routing/route-paths';
|
||||
|
||||
export type SystemsMapNodeKind = 'ai' | 'cluster' | 'hardware' | 'software' | 'project';
|
||||
export type SystemsMapNodeKind = 'infrastructure' | 'platform' | 'software' | 'ai' | 'project';
|
||||
|
||||
export type SystemsMapNodeId =
|
||||
| 'ai'
|
||||
| 'clusters'
|
||||
| 'hardware'
|
||||
| 'infrastructure'
|
||||
| 'platform'
|
||||
| 'software'
|
||||
| 'stack'
|
||||
| 'caseMigration'
|
||||
| 'casePlatform'
|
||||
| 'caseAutomation';
|
||||
| 'ai'
|
||||
| 'caseInnofocus'
|
||||
| 'caseRoesterei'
|
||||
| 'caseBannier'
|
||||
| 'caseHdi';
|
||||
|
||||
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
|
||||
'ai',
|
||||
'cluster',
|
||||
'hardware',
|
||||
'infrastructure',
|
||||
'platform',
|
||||
'software',
|
||||
'ai',
|
||||
'project',
|
||||
];
|
||||
|
||||
@@ -25,9 +26,12 @@ export interface SystemsMapNode {
|
||||
readonly id: SystemsMapNodeId;
|
||||
readonly kind: SystemsMapNodeKind;
|
||||
readonly routeId: RouteId;
|
||||
readonly fragment: string;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
readonly labelLines: Record<AppLocale, readonly string[]>;
|
||||
readonly summary: Record<AppLocale, string>;
|
||||
readonly gist: Record<AppLocale, string>;
|
||||
readonly relationships: Record<AppLocale, string>;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
@@ -39,121 +43,192 @@ export interface SystemsMapEdge {
|
||||
|
||||
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
{
|
||||
id: 'hardware',
|
||||
kind: 'hardware',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
label: { de: 'Hardware', en: 'Hardware' },
|
||||
labelLines: { de: ['Hardware'], en: ['Hardware'] },
|
||||
id: 'infrastructure',
|
||||
kind: 'infrastructure',
|
||||
routeId: 'services',
|
||||
fragment: 'infrastructure-network',
|
||||
label: { de: 'Server und Netz', en: 'Infrastructure' },
|
||||
labelLines: { de: ['Server und', 'Netz'], en: ['Infrastructure'] },
|
||||
summary: {
|
||||
de: 'Geräte, Netz und physische Schicht',
|
||||
en: 'Devices, network and the physical layer',
|
||||
de: 'Netz, Server und Virtualisierung vor Ort',
|
||||
en: 'Servers, networks and virtualisation',
|
||||
},
|
||||
x: 140,
|
||||
y: 300,
|
||||
gist: {
|
||||
de: 'Die unterste Schicht: Netzstrukturen, Server und Virtualisierung im Haus. Was darüber läuft, hängt an ihrer Stabilität.',
|
||||
en: 'Servers, company networks, virtualisation and on-premise structures — the layer everything else on this map stands on.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Trägt den Plattformbetrieb und ist die Grundlage der laufenden Betreuung bei der Fahrschule Bannier und bei der Rösterei Tangermünde.',
|
||||
en: 'Carries the platform layer and the software above it. The Bannier and Rösterei cases attach here directly.',
|
||||
},
|
||||
x: 160,
|
||||
y: 320,
|
||||
},
|
||||
{
|
||||
id: 'clusters',
|
||||
kind: 'cluster',
|
||||
routeId: 'servicesClusters',
|
||||
label: { de: 'Cluster', en: 'Clusters' },
|
||||
labelLines: { de: ['Cluster'], en: ['Clusters'] },
|
||||
id: 'platform',
|
||||
kind: 'platform',
|
||||
routeId: 'services',
|
||||
fragment: 'platform-operations',
|
||||
label: { de: 'Plattform', en: 'Platform' },
|
||||
labelLines: { de: ['Plattform'], en: ['Platform'] },
|
||||
summary: {
|
||||
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
|
||||
en: 'Orchestration and cluster operations',
|
||||
de: 'Microsoft 365, Container, Cluster und Pipelines',
|
||||
en: 'Microsoft 365, containers, clusters and pipelines',
|
||||
},
|
||||
x: 340,
|
||||
y: 140,
|
||||
gist: {
|
||||
de: 'Die Betriebsschicht zwischen Blech und Anwendung: Microsoft 365, Container, Kubernetes und die Pipelines, mit denen ein Stand ausgerollt wird. Hier entscheidet sich auch, wer danach betreibt.',
|
||||
en: 'Microsoft 365, Docker and Kubernetes on-premise or on Azure AKS, CI/CD pipelines and GitOps. This is where software gets deployed, run and handed over.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Steht auf Server und Netz, trägt die Produktsoftware und zeigt sich am deutlichsten im Fall HDI Specialty.',
|
||||
en: 'Sits on the infrastructure and carries the software above it. HDI Specialty is the case that documents this layer.',
|
||||
},
|
||||
x: 380,
|
||||
y: 160,
|
||||
},
|
||||
{
|
||||
id: 'software',
|
||||
kind: 'software',
|
||||
routeId: 'servicesSoftware',
|
||||
label: { de: 'Software', en: 'Software' },
|
||||
labelLines: { de: ['Software'], en: ['Software'] },
|
||||
routeId: 'services',
|
||||
fragment: 'software-data',
|
||||
label: { de: 'Software und Daten', en: 'Software' },
|
||||
labelLines: { de: ['Software', 'und Daten'], en: ['Software'] },
|
||||
summary: {
|
||||
de: 'Anwendungen und Schnittstellen',
|
||||
en: 'Applications and interfaces',
|
||||
de: 'Produktsoftware, Schnittstellen und SQL-Datenarbeit',
|
||||
en: 'Product software, APIs and data work',
|
||||
},
|
||||
x: 520,
|
||||
y: 300,
|
||||
gist: {
|
||||
de: 'Anwendungen von der Fachlichkeit über die Schnittstelle bis zur Oberfläche, dazu die Datenarbeit in SQL und T-SQL. Der Aufwand steckt meistens in den Daten, nicht im Bildschirm.',
|
||||
en: 'Fullstack product software from the domain through the API to the screen, plus SQL and T-SQL data work and migrations. It runs on the platform below it.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Läuft auf der Plattformschicht, liefert die Abläufe, an die KI-Werkzeuge andocken, und ist der Kern des Falls Innofocus / reifen.com.',
|
||||
en: 'Runs on the platform, feeds the AI workflows above, and is what the Innofocus / reifen.com case is about.',
|
||||
},
|
||||
x: 600,
|
||||
y: 320,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
kind: 'ai',
|
||||
routeId: 'servicesAi',
|
||||
label: { de: 'KI', en: 'AI' },
|
||||
labelLines: { de: ['KI'], en: ['AI'] },
|
||||
routeId: 'services',
|
||||
fragment: 'ai-workflows',
|
||||
label: { de: 'KI-Abläufe', en: 'AI' },
|
||||
labelLines: { de: ['KI-Abläufe'], en: ['AI'] },
|
||||
summary: {
|
||||
de: 'Angebot: lokale Modelle und Integrationsarbeit',
|
||||
en: 'Offered as local models and integration work',
|
||||
de: 'Zwei umgesetzte Werkzeuge, der Rest ist Angebot',
|
||||
en: 'Two delivered tools, everything else scoped per case',
|
||||
},
|
||||
x: 720,
|
||||
y: 140,
|
||||
gist: {
|
||||
de: 'KI hängt hier an einem konkreten Ablauf. Zwei Werkzeuge sind bei der Rösterei Tangermünde umgesetzt, weitere Bausteine werden je Auftrag geschnitten und geprüft.',
|
||||
en: 'Two AI tools are delivered and in daily use at Rösterei Tangermünde: review categorisation and a shop-management assistant. Everything beyond those two is offered and scoped per engagement.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Hängt an der Software- und Datenschicht und ist ausschließlich im Fall Rösterei Tangermünde belegt.',
|
||||
en: 'Sits on top of the software and the platform. Rösterei Tangermünde is the only case behind it.',
|
||||
},
|
||||
x: 820,
|
||||
y: 160,
|
||||
},
|
||||
{
|
||||
id: 'stack',
|
||||
kind: 'software',
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
labelLines: { de: ['Stack'], en: ['Stack'] },
|
||||
id: 'caseInnofocus',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
fragment: 'innofocus',
|
||||
label: { de: 'Innofocus', en: 'Innofocus' },
|
||||
labelLines: { de: ['Innofocus'], en: ['Innofocus'] },
|
||||
summary: {
|
||||
de: 'Werkzeuge und Laufzeitumgebung',
|
||||
en: 'Tools and runtime environment',
|
||||
de: 'ERP-Ablösung mit vollständiger Datenmigration',
|
||||
en: 'ERP data migration in SQL Server',
|
||||
},
|
||||
x: 340,
|
||||
gist: {
|
||||
de: 'Ablösung des bestehenden ERP durch ein normalisiertes System, mit vollständiger Migration der Altdaten im Microsoft SQL Server. Ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden.',
|
||||
en: 'Sole responsibility for moving a grown ERP data store into a normalised system inside Microsoft SQL Server. The migration run went from around eight hours to about 90 minutes.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Belegt die Software- und Datenschicht und reicht über Kubernetes, Argo CD und Azure DevOps in die Plattformschicht.',
|
||||
en: 'Attaches to software and data, and reaches into the platform layer through Kubernetes and Argo CD.',
|
||||
},
|
||||
x: 820,
|
||||
y: 460,
|
||||
},
|
||||
{
|
||||
id: 'caseMigration',
|
||||
id: 'caseRoesterei',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Datenmigration', en: 'Data migration' },
|
||||
labelLines: { de: ['Datenmigration'], en: ['Data migration'] },
|
||||
fragment: 'roesterei',
|
||||
label: { de: 'Rösterei', en: 'Rösterei' },
|
||||
labelLines: { de: ['Rösterei'], en: ['Rösterei'] },
|
||||
summary: {
|
||||
de: 'Datenbestände strukturiert überführen',
|
||||
en: 'Moving data stores in a structured way',
|
||||
de: 'Gesamte Technik: Netz, Microsoft 365, Shop, KI-Werkzeuge',
|
||||
en: 'One company, from the network to the shop',
|
||||
},
|
||||
x: 860,
|
||||
gist: {
|
||||
de: 'Die gesamte Technik eines Betriebs in einer Hand, vom Netz über Microsoft 365 bis zum Onlineshop und zwei internen KI-Werkzeugen. Ich bin an dem Unternehmen wirtschaftlich beteiligt.',
|
||||
en: 'Full technical responsibility for Rösterei Tangermünde: network and server structures, Microsoft 365, the online shop and two internal AI tools. I hold an economic participation in the company.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Berührt alle vier Bereiche und ist der einzige Beleg für umgesetzte KI-Arbeit.',
|
||||
en: 'Touches all four areas, and it is the only case behind the AI workflows.',
|
||||
},
|
||||
x: 380,
|
||||
y: 460,
|
||||
},
|
||||
{
|
||||
id: 'casePlatform',
|
||||
id: 'caseBannier',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
|
||||
labelLines: { de: ['Plattform und', 'Betrieb'], en: ['Platform and', 'operations'] },
|
||||
fragment: 'bannier',
|
||||
label: { de: 'Bannier', en: 'Bannier' },
|
||||
labelLines: { de: ['Bannier'], en: ['Bannier'] },
|
||||
summary: {
|
||||
de: 'Plattformen betreiben und weiterentwickeln',
|
||||
en: 'Operating and evolving platforms',
|
||||
de: 'Gesamte IT einer Fahrschule seit etwa 2018',
|
||||
en: "A driving school's whole IT since roughly 2018",
|
||||
},
|
||||
x: 860,
|
||||
y: 300,
|
||||
gist: {
|
||||
de: 'Rechner, Netz, Support und Website einer Fahrschule, durchgehend betreut seit etwa 2018. Die eingesetzte Fachsoftware betreibe und betreue ich; entwickelt habe ich sie nicht.',
|
||||
en: 'The entire IT of Fahrschule Bannier — the computers, the website, and the support and operation of the specialist software the school uses. I operate that software; I did not develop it.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Belegt Server und Netz sowie den laufenden Betrieb; Software und Daten berührt der Fall nicht.',
|
||||
en: 'Attaches to infrastructure and to day-to-day operation. It does not touch the software and data layer.',
|
||||
},
|
||||
x: 160,
|
||||
y: 460,
|
||||
},
|
||||
{
|
||||
id: 'caseAutomation',
|
||||
id: 'caseHdi',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
|
||||
labelLines: { de: ['Automatisierung', 'und AI'], en: ['Automation', 'and AI'] },
|
||||
fragment: 'hdi',
|
||||
label: { de: 'HDI Specialty', en: 'HDI' },
|
||||
labelLines: { de: ['HDI', 'Specialty'], en: ['HDI'] },
|
||||
summary: {
|
||||
de: 'Angebot: Abläufe automatisieren und Modelle anbinden',
|
||||
en: 'Offered as workflow automation and model connections',
|
||||
de: 'Exposure Management und Umzug auf Azure AKS',
|
||||
en: 'Exposure management, moved onto Azure AKS',
|
||||
},
|
||||
x: 900,
|
||||
gist: {
|
||||
de: 'Fullstack- und DevOps-Arbeit an einer Software für das Exposure Management, einschließlich der Auswertungen für die BaFin. Entwicklungs-, QS- und Produktivumgebung zogen von Azure App Services auf Azure AKS um.',
|
||||
en: 'Senior fullstack and DevOps work on exposure-management software for simulations, stochastic evaluation and BaFin reporting. Dev, QA and production moved from Azure App Services onto Azure AKS.',
|
||||
},
|
||||
relationships: {
|
||||
de: 'Belegt die Plattformschicht und reicht über Angular und .NET in Software und Daten hinein.',
|
||||
en: 'Attaches to the platform layer, and to software through the Angular and .NET work.',
|
||||
},
|
||||
x: 600,
|
||||
y: 140,
|
||||
},
|
||||
];
|
||||
|
||||
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
|
||||
{ from: 'hardware', to: 'clusters' },
|
||||
{ from: 'clusters', to: 'software' },
|
||||
{ from: 'infrastructure', to: 'platform' },
|
||||
{ from: 'platform', to: 'software' },
|
||||
{ from: 'software', to: 'ai' },
|
||||
{ from: 'ai', to: 'clusters' },
|
||||
{ from: 'hardware', to: 'software' },
|
||||
{ from: 'stack', to: 'software' },
|
||||
{ from: 'ai', to: 'caseAutomation' },
|
||||
{ from: 'software', to: 'casePlatform' },
|
||||
{ from: 'software', to: 'caseMigration' },
|
||||
{ from: 'infrastructure', to: 'caseBannier' },
|
||||
{ from: 'infrastructure', to: 'caseRoesterei' },
|
||||
{ from: 'platform', to: 'caseHdi' },
|
||||
{ from: 'software', to: 'caseInnofocus' },
|
||||
{ from: 'ai', to: 'caseRoesterei' },
|
||||
];
|
||||
|
||||
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
|
||||
@@ -169,3 +244,7 @@ export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeI
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
export function nodeHref(node: SystemsMapNode, locale: AppLocale): string {
|
||||
return `${routePath(node.routeId, locale)}#${node.fragment}`;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user