47 Commits

Author SHA1 Message Date
e63b4edade Restore the original Quartz Producer on the CV without duplicating the pitch link.
The script now repairs metadata independently of the annotation, and a repeat run leaves the bytes untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:51:58 +02:00
b8b9e57d06 Make the terminal dock toggle, scroll, and remember commands honestly.
The desktop trigger now collapses through the shared toggle, the log stays on the newest line, Shift+K is left to the browser, history walks like a shell, and maximize keeps one accessible name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:51:55 +02:00
19d00da9bb Collapse the terminal dock from Escape anywhere in the panel.
Drop unused suggestion state and copy, and give the panel a reduced-motion-aware open transition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:43 +02:00
35a575dba9 Read Pitch as a profile: stations and stack before the case cards.
The four public cases stay on the page; the timeline no longer follows them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:43 +02:00
255f0eace6 Stop the Pitch timeline from repeating dates and talking about omitted stations.
Roles keep the date in period only, self-employment is described as the work itself, and Home workplace copy stays plain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:13:40 +02:00
359e887a7a Align tests and docs with Pitch, the customer Home, and the terminal dock.
Page, crawl, Playwright and Lighthouse coverage now include both pitch locales, and the contributor docs match the new route table and chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:45 +02:00
5921fc5ede Add a reproducible pitch URL and link annotation to the CV.
The script is idempotent, keeps the twelve A4 pages, and writes antoniolede.de/pitch on page 1 with a matching URI annotation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:26 +02:00
162bd790da Replace the modal command palette with a nonmodal terminal dock.
Ctrl+K now opens a corner session with a parsed navigate grammar, persistent history, and no page lock. Unknown targets stay on a closed alias table and never become URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:22 +02:00
646b39b200 Make the primary nav customer-facing and keep the services submenu readable.
Stack and the CV leave the chrome so Contact stays the main action. The desktop submenu now uses an opaque surface token and a stacking context above page content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:17 +02:00
86d6dd085d Add the recruiter Pitch route and rebuild Home for direct-customer work.
Pitch takes the verified profile, metrics and public cases off Home so `/` can speak to small companies in plain language. The Systems Map now sits on the services overview, and both locales stay table-driven.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 12:52:12 +02:00
b15dfd7801 integration: compact the wide primary navigation and keep fragment targets visible
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:55:22 +02:00
575ba06bb9 integration: disable the contact mailto until required fields are complete
Keep the submit control focusable with a disabled semantic while the briefing is incomplete, mark invalid fields only after interaction, and leave the encoded mailto path unchanged once the form is filled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:22 +02:00
1ff26be61b integration: keep reveal transitions on the revealed state
Move the transition onto the base reveal-target selector so removing reveal-pending no longer drops the animation, while reduced-motion still suppresses it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:22 +02:00
1de146c650 integration: name case metrics, split delivered offerings from offers, and frame the stack honestly
Give every metrics list a localized heading, keep delivered Rösterei tools out of the offer boundary, and describe the skills grid as a familiarity overview with an evidence note instead of claiming public-case backing for every item.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:18 +02:00
1d2965a7ff integration: collapse the mobile nav after hydration and keep fragment targets clear of the sticky header
SSR still ships the expanded menu, but phones collapse it after render, stop sticking the header below md, and use the header-offset token so case anchors no longer sit underneath the bar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:32:13 +02:00
271df12334 integration: return HTTP 404 for unmatched URLs and close remaining browser-gate gaps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:45:28 +02:00
34367181d2 integration: add Playwright, axe and Lighthouse CI gates
Lock SEO, accessibility and crawlability with headless browser checks so regressions fail before a merge instead of after publication.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:28:24 +02:00
46c86447d1 integration: add route metadata, JSON-LD and harden the public shell
Ship prerendered SEO, crawl files and palette/map a11y so every locale route is indexable, honest and keyboard-usable without inventing claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:28:22 +02:00
2ccac6b28a merge: integrate signature UX domain 2026-08-25 17:57:03 +02:00
da38cc3624 merge: integrate bilingual content domain 2026-08-25 17:57:03 +02:00
763bf435c9 content: drop repeated reference headings and land case CTAs on anchors
Service notes now say why the public case fits, the four case CTAs
open the matching project fragment, and the Innofocus run-time figure
is a case metric as well as a home tile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:52:37 +02:00
6c1572bf3c signature: restore lean dots, reduced-motion redraw, above-fold reveal and map focus rings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:49:57 +02:00
cfc7580a8f content: repair visitor-facing copy and briefing accessibility
Public pages no longer describe their own pipeline, case and service
labels are real accessible names, and the contact briefing writes
localized fields without empty optional lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:46:32 +02:00
7813499ee3 signature: add systems map, command palette and motion primitives
Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:37:25 +02:00
d954361724 content: replace scaffolding with bilingual pages and case studies
Ship the final German and English copy, compose every route from shared page primitives, and cover claims, parity and contact briefing in tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:34:04 +02:00
9ea2885c7b foundation: resolve platform in injection context for dot background teardown
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:12:10 +02:00
42e9f01253 foundation: project guide, tooling, design tokens, bilingual shell and routing contracts
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:06:51 +02:00
1020ac4c68 added cv 2026-08-25 15:21:25 +02:00
95193cf99e added CV 2026-08-25 15:19:58 +02:00
6b170c6864 disabled hover effects on mobile 2026-01-21 20:08:41 +01:00
55a63047cd fix jumping when resizing 2026-01-21 19:15:38 +01:00
e87dd76626 add ball with click but remove oldest dot when limit reached 2026-01-21 18:52:03 +01:00
fb917d56e6 add ball with click but remove oldest dot when limit reached 2026-01-21 18:46:12 +01:00
47920509d3 add ball with click 2026-01-21 18:27:02 +01:00
010520f722 can't trap dots in corner anymore 2026-01-21 17:42:29 +01:00
66a8bacbe8 mobile jumping background fix 2026-01-21 17:30:40 +01:00
f9b4054571 mobile jumping background fix 2026-01-21 08:29:46 +01:00
eac47338a0 mobile jumping background fix 2026-01-21 08:29:03 +01:00
258433e6c2 mobile jumping background fix 2026-01-21 08:22:36 +01:00
0c0c4ca2dd set color scheme 2026-01-21 08:20:34 +01:00
f4bffc353b fix resizing issue on mobile 2026-01-21 08:16:07 +01:00
11b132756a added dockerfiles 2026-01-21 08:02:49 +01:00
f847c5bafc added dockerfiles 2026-01-21 07:56:54 +01:00
23748adca1 added dockerfiles 2026-01-21 07:55:00 +01:00
da38de3159 added skill-cards 2026-01-21 07:28:34 +01:00
7f7ff008e2 added background 2026-01-21 07:28:26 +01:00
e58c7dc287 added SVGs 2026-01-21 07:28:08 +01:00
226 changed files with 18339 additions and 464 deletions

6
.gitignore vendored
View File

@@ -41,3 +41,9 @@ __screenshots__/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Browser and audit artifacts
/test-results
/playwright-report
/blob-report
/.lighthouseci

14
.prettierignore Normal file
View File

@@ -0,0 +1,14 @@
dist
.angular
node_modules
coverage
package-lock.json
public/**/*.svg
*.pdf
Dockerfile
docker-compose.yml
.cursor
test-results
playwright-report
.lighthouseci
blob-report

161
AGENTS.md Normal file
View File

@@ -0,0 +1,161 @@
# AGENTS.md
Contributor and agent guide for this repository. Read this before changing routing, copy, styles, or SSR behaviour.
## Purpose
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
- Angular 21 standalone components, no NgModules
- Zoneless change detection
- Signals for local state
- Lazy-loaded feature routes
- SSR with prerendering (`@angular/ssr`, `outputMode: "server"`)
- Express host in `src/server.ts`
## Directory map
| Path | Role |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/app/core/**` | Contracts, services and tokens. No UI. |
| `src/app/features/**` | Routed page components, one folder per route. |
| `src/app/shared/**` | Reusable UI primitives used by multiple features. |
| `src/app/components/**` | Existing decorative/feature widgets (skills grid, dot background). |
| `public/**` | Static SVG and public assets copied as-is. |
| `src/styles.scss` | Global design tokens and primitives. The only place raw brand hex values are defined. |
| `src/_breakpoints.scss` | Breakpoint map (`sm` 40rem, `md` 48rem, `lg` 64rem, `xl` 80rem) and the `respond-to` mixin. Components pull it in with `@use 'breakpoints'`. |
## Commands
| Script | Purpose |
| ----------------------------- | --------------------------------------------- |
| `npm start` | Dev server (`ng serve`). |
| `npm run build` | Production build (default configuration). |
| `npm run watch` | Development rebuild on change. |
| `npm test` | Unit tests in watch mode. |
| `npm run test:ci` | Unit tests once (`--watch=false`); must exit. |
| `npm run lint` | ESLint over the workspace. |
| `npm run lint:fix` | ESLint with autofix. |
| `npm run format` | Prettier write. |
| `npm run format:check` | Prettier check (CI). |
| `npm run serve:ssr` | Serve the production SSR bundle. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
| `npm run e2e` | Playwright + axe-core against the SSR bundle. |
| `npm run e2e:install` | Install the pinned Chromium build. |
| `npm run lighthouse` | Production build, then Lighthouse CI. |
| `npm run ci` | lint, format:check, test:ci, then build. |
## Code conventions
- Standalone components only
- `inject()` over constructor injection for new code
- Signals over RxJS for local state
- `ChangeDetectionStrategy.OnPush` on new components
- kebab-case file names
- No `any`
- No default exports
- Templates in separate files
## SCSS conventions
- Global design tokens live only in `src/styles.scss` as CSS custom properties
- Components consume `var(--...)` and never redefine raw hex values
- Use the documented spacing (`--space-1``--space-12`) and typography (`--text-xs``--text-3xl`) scale
- Hover-only affordances must be wrapped in `@media (hover: hover) and (pointer: fine)`
- All motion must respect `prefers-reduced-motion`
- Breakpoints and the `respond-to` mixin live in `src/_breakpoints.scss`; do not invent extra breakpoint names
- Shared glass, layout and focus treatments belong on the global primitives (`.glass-surface`, `.content-container`, `.stack`, `.cluster`), not copied into component stylesheets
- Keep component styles under the `anyComponentStyle` budget (4 kB warn, 8 kB error)
## Content rules
All user-facing copy comes from the typed bilingual content layer in `src/app/core/content`. Never inline marketing or page copy in templates.
Keep the four copy roles as separate fields:
- `headline` — the serious heading
- `proof` — only a verifiable statement
- `playfulLine` — optional wordplay; never a capability claim
- `cta` — action label, kept swappable
German and English are written idiomatically per language, never machine-translated word for word.
## Claims policy (non-negotiable)
- No unsupported superlatives
- No invented references, client names, metrics or certifications
- Unproven AI, local-LLM or multi-agent capabilities are phrased as an offering or way of working, never as a delivered reference
- The CV stations HUP, BitWiz and Cybertrading are deliberately excluded from all public pages, cases and timelines
- The legal pages (Impressum, Datenschutz) contain placeholders that MUST be reviewed by the site owner before publication and must visibly say so
## 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`
## SSR rules
- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards
- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser)
- No user-agent sniffing; use CSS media queries for device capability
- Exception: `isApplePlatform()` in `src/app/core/platform/browser.ts` may read `navigator.userAgentData?.platform ?? navigator.platform` after hydration, only to label the Command key. There is no CSS media query for that key. It is not used for device capability.
- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server
- Helpers that use `inject()` must be called from a field initializer or constructor, never from a lifecycle hook or callback, and the resolved value must be stored on the instance
- Every addressable route must remain prerenderable
## Accessibility checklist
- Skip link as the first focusable element, targeting `#main-content`
- Single `h1` per page
- Landmark elements (`header`/`nav`, `main`, `footer`)
- Visible `:focus-visible` ring
- Keyboard reachability for every action
- `aria-current` on the active nav item
- At least 4.5:1 text contrast on the base surface
- Reduced-motion alternatives for animation and smooth scrolling
- Navigation remains usable in its server-rendered default state (no JS-only menus)
## SEO expectations
- Semantic headings
- Real locale-prefixed URLs
- Server-rendered core text
- Per-route titles from the content layer
- Canonical origin is `SITE_CONFIG.siteOrigin` (`https://antoniolede.de`); every absolute URL in the app and tests is derived from it
- Per-route metadata (description, canonical, reciprocal hreflang including `x-default`, Open Graph, Twitter, robots) is written by `SeoService` during SSR and on every client navigation
- One JSON-LD `@graph` script per route: Person and ProfessionalService on Home, Service on service routes, CreativeWork per public case on projects, WebPage elsewhere
- `public/robots.txt`, `public/sitemap.xml` and `public/llms.txt` stay synchronized with `prerenderablePaths()` via `crawl-assets.spec.ts`
- The language switch exposes `hreflang` on the alternate-locale link
Browser binaries, HTML reports, traces and screenshots are not committed (`test-results`, `playwright-report`, `.lighthouseci`).
## Test checklist
A change is not done until:
1. `npm run lint` exits 0
2. `npm run format:check` exits 0
3. `npm run test:ci` exits 0 with no watcher left running
4. `npm run build` exits 0
5. New or changed routes appear in `prerenderablePaths()` and are prerendered
6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links)
7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard
8. Placeholder or legal copy was not replaced with invented professional claims
9. Crawl files still match `SITE_CONFIG.siteOrigin` and `prerenderablePaths()`
10. Playwright (`npm run e2e`) and Lighthouse CI (`npm run lighthouse`) still pass when the change affects public HTML, metadata or chrome
## Domain boundaries
| Branch | Owns |
| ----------- | -------------------------------------------------------------------------------- |
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
| Content | Bilingual copy data and page composition |
| 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.

BIN
CV.pdf Normal file

Binary file not shown.

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:24-alpine as build
WORKDIR /app/src
COPY package*.json ./
RUN npm ci
COPY . ./
RUN npm run build
FROM node:24-alpine
WORKDIR /usr/app
COPY --from=build /app/src/dist/Portfolio ./
CMD node server/server.mjs
EXPOSE 4000

View File

@@ -1,59 +1,68 @@
# Portfolio # Portfolio
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5. 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.
## Development server This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
To start a local development server, run: ## Prerequisites
- Node.js 22 or newer
- npm 11 or newer (npm 12 prints an engine-compatibility warning on some Node 24 builds; that warning is not an error)
## Install
```bash ```bash
ng serve npm install
``` ```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. ## Scripts
## Code scaffolding | 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). |
Angular CLI includes powerful code scaffolding tools. To generate a new component, run: ## Local SSR build
```bash ```bash
ng generate component component-name npm run build
npm run serve:ssr
``` ```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: 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`).
```bash `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.
ng generate --help
## Project structure
```
src/app/core/ Typed contracts: locale, routing, navigation, content, platform
src/app/features/ One standalone page per route id
src/app/shared/ Reusable UI primitives
src/app/components/ Existing decorative widgets (skills, dot background)
src/styles.scss Design tokens and global primitives
src/_breakpoints.scss Breakpoint map and respond-to mixin
public/ Static SVG assets
``` ```
## Building Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the placeholder provider live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`.
To build the project run: Contributor and agent conventions are in [AGENTS.md](./AGENTS.md).
```bash ## Legal pages
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. The Impressum (`/impressum`, `/en/legal-notice`) and Datenschutz (`/datenschutz`, `/en/privacy`) pages contain unreviewed placeholders. They must be checked by the site owner before publication.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@@ -23,15 +23,21 @@
"browser": "src/main.ts", "browser": "src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"stylePreprocessorOptions": {
"includePaths": ["src"]
},
"assets": [ "assets": [
{ {
"glob": "**/*", "glob": "**/*",
"input": "public" "input": "public"
},
{
"glob": "CV.pdf",
"input": ".",
"output": "cv"
} }
], ],
"styles": [ "styles": ["src/styles.scss"],
"src/styles.scss"
],
"server": "src/main.server.ts", "server": "src/main.server.ts",
"outputMode": "server", "outputMode": "server",
"ssr": { "ssr": {

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
portfolio-fe:
build: .
restart: always
environment:
VIRTUAL_HOST: antoniolede.de,antoniolede.com,antonioledebuhr.de,www.antoniolede.de,www.antoniolede.com,www.antonioledebuhr.de
VIRTUAL_PORT: 4000
VIRTUAL_PATH: /
VIRTUAL_DEST: /
NETWORK_ACCESS: cf
networks:
- proxy
networks:
proxy:
external: true

53
e2e/a11y.e2e.ts Normal file
View File

@@ -0,0 +1,53 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
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'),
pagePath('servicesAi', 'en'),
pagePath('contact', 'de'),
pagePath('contact', 'en'),
pagePath('imprint', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
'/en/missing-route',
];
test.describe('accessibility', () => {
test('revealed content stays visible under reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(pagePath('home', 'de'));
const pending = page.locator('.reveal-pending');
await expect(pending).toHaveCount(0);
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) {
test(`axe has no serious or critical violations on ${path}`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page }).analyze();
const blocking = results.violations.filter(
(violation) => violation.impact === 'serious' || violation.impact === 'critical',
);
const details = blocking
.map((violation) => {
const nodes = violation.nodes.map((node) => node.target.join(' ')).join(', ');
return `${violation.id} (${violation.impact}): ${nodes}`;
})
.join('\n');
expect(blocking, details).toEqual([]);
});
}
});

41
e2e/contact.e2e.ts Normal file
View File

@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { pagePath } from './helpers';
test.describe('contact briefing', () => {
test('builds a mailto href and issues no network request', async ({ page }) => {
const extras: string[] = [];
await page.route('**/*', (route) => {
const url = route.request().url();
const resource = route.request().resourceType();
if (resource !== 'document' && !url.startsWith('data:') && !url.startsWith('blob:')) {
extras.push(`${resource} ${url}`);
}
void route.continue();
});
await page.goto(pagePath('contact', 'de'), { waitUntil: 'networkidle' });
extras.length = 0;
const emptySubmit = page.locator('.submit');
await expect(emptySubmit).toHaveAttribute('aria-disabled', 'true');
expect(await emptySubmit.getAttribute('href')).toBeNull();
await page.locator('#contact-name').fill('Ada');
await page.locator('#contact-email').fill('ada@example.com');
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));
expect(href).toContain(encodeURIComponent('Ada'));
expect(href).toContain(SITE_CONFIG.contactEmail);
expect(extras, extras.join('\n')).toEqual([]);
});
});

79
e2e/fragments.e2e.ts Normal file
View File

@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test';
import { APP_LOCALES } from '../src/app/core/i18n/locale';
import { pagePath } from './helpers';
const VIEWPORTS = [768, 820, 1024, 1280, 1440] as const;
const VIEWPORT_HEIGHT = 900;
const MAX_HEADER_HEIGHT = 200;
async function waitForScrollSettle(page: Page): Promise<void> {
await page.evaluate(async () => {
await new Promise<void>((resolve) => {
let last = window.scrollY;
let stableFrames = 0;
const tick = () => {
if (window.scrollY === last) {
stableFrames += 1;
if (stableFrames >= 8) {
resolve();
return;
}
} else {
stableFrames = 0;
last = window.scrollY;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
});
}
test.describe('fragment scrolling', () => {
test('case headings stay fully visible under the compact header', async ({ page }, testInfo) => {
test.skip(
testInfo.project.name === 'mobile',
'desktop widths are measured here to avoid a duplicate run',
);
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
for (const locale of APP_LOCALES) {
const path = `${pagePath('projects', locale)}#myspa`;
const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header');
const heading = page.locator('#myspa h2');
await expect(heading).toBeVisible();
await waitForScrollSettle(page);
const headerBox = await header.boundingBox();
expect(headerBox, label).toBeTruthy();
expect(
headerBox!.height,
`header height at ${label} must be at most ${MAX_HEADER_HEIGHT}px`,
).toBeLessThanOrEqual(MAX_HEADER_HEIGHT);
expect(
headerBox!.height,
`header height at ${label} must be at most 25% of the viewport`,
).toBeLessThanOrEqual(VIEWPORT_HEIGHT * 0.25);
await expect(header).not.toHaveCSS('position', 'sticky');
await expect(heading).toBeInViewport({ ratio: 1 });
const headingOwnsCentre = await heading.evaluate((element) => {
const rect = element.getBoundingClientRect();
const node = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
);
return node !== null && (element === node || element.contains(node));
});
expect(headingOwnsCentre, `heading centre must not be covered at ${label}`).toBe(true);
}
}
});
});

97
e2e/helpers.ts Normal file
View File

@@ -0,0 +1,97 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { type AppLocale } from '../src/app/core/i18n/locale';
import { LOCALE_HTML_LANG } from '../src/app/core/i18n/locale';
import { type RouteId } from '../src/app/core/routing/route-ids';
import { routePath } from '../src/app/core/routing/route-paths';
import { buildRouteMetadata } from '../src/app/core/seo/route-metadata';
export const ORIGIN = SITE_CONFIG.siteOrigin;
export function pagePath(routeId: RouteId, locale: AppLocale): string {
return routePath(routeId, locale);
}
export async function readHtml(
request: APIRequestContext,
path: string,
expectedStatus: number,
): Promise<string> {
const response = await request.get(path);
expect(response.status(), `GET ${path} status`).toBe(expectedStatus);
return response.text();
}
export function attr(html: string, selector: string, attribute: string): string | null {
const pattern = new RegExp(
`<[^>]*${selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*${attribute}="([^"]*)"`,
'i',
);
return html.match(pattern)?.[1] ?? null;
}
export function count(html: string, snippet: string): number {
return html.split(snippet).length - 1;
}
export async function expectHead(
request: APIRequestContext,
routeId: RouteId,
locale: AppLocale,
): Promise<void> {
const path =
routeId === 'notFound'
? locale === 'en'
? '/en/missing-route'
: '/missing-route'
: pagePath(routeId, locale);
const expectedStatus = routeId === 'notFound' ? 404 : 200;
const html = await readHtml(request, path, expectedStatus);
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
expect(html, `${path} title`).toContain(`<title>${metadata.title}</title>`);
expect(html).toContain(`name="description"`);
expect(html).toContain(`content="${metadata.description}"`);
expect(html).toContain(`name="robots"`);
expect(html).toContain(`content="${metadata.robots}"`);
expect(html).toContain(`<html lang="${LOCALE_HTML_LANG[locale]}"`);
expect(count(html, 'name="description"')).toBe(1);
expect(count(html, 'name="robots"')).toBe(1);
expect(count(html, 'type="application/ld+json"')).toBe(1);
if (routeId === 'notFound') {
expect(html).not.toMatch(/rel="canonical"/);
expect(html).not.toMatch(/rel="alternate"[^>]*hreflang/);
return;
}
expect(html).toContain(`rel="canonical"`);
expect(html).toContain(`href="${metadata.canonical}"`);
expect(count(html, 'rel="canonical"')).toBe(1);
expect(html).toContain('hreflang="de-DE"');
expect(html).toContain('hreflang="en"');
expect(html).toContain('hreflang="x-default"');
expect(html).toContain('property="og:type"');
expect(html).toContain('property="og:title"');
expect(html).toContain('property="og:description"');
expect(html).toContain('property="og:url"');
expect(html).toContain('property="og:site_name"');
expect(html).toContain('property="og:locale"');
expect(html).toContain('name="twitter:card"');
expect(html).toContain('name="twitter:title"');
expect(html).toContain('name="twitter:description"');
}
export async function expectNoOverflow(page: Page, route: string, width: number): Promise<void> {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
);
expect(overflow, `horizontal overflow at ${width}px on ${route}`).toBe(true);
}
export async function jsonLdGraph(page: Page): Promise<unknown> {
const raw = await page.locator('script[type="application/ld+json"]').first().textContent();
expect(raw).toBeTruthy();
return JSON.parse(raw ?? 'null');
}

118
e2e/keyboard.e2e.ts Normal file
View File

@@ -0,0 +1,118 @@
import { expect, test } from '@playwright/test';
import { pagePath } from './helpers';
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');
const skip = page.locator('.skip-link');
await expect(skip).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('#main-content')).toBeFocused();
});
test('mobile nav toggle keeps aria-expanded in sync', async ({ page, request }) => {
const ssr = await request.get('/');
expect(ssr.ok()).toBe(true);
const html = await ssr.text();
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('id="primary-nav"');
expect(html).toContain('class="site-nav"');
expect(html).toMatch(/<div[^>]*class="site"/);
expect(html).not.toMatch(/<div[^>]*class="[^"]*\bsite\b[^"]*\bnav-collapsed\b/);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const toggle = page.locator('.nav-toggle');
await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
test('collapsed header stays under 25% of the viewport and is not sticky below md', async ({
page,
}) => {
const viewport = { width: 390, height: 844 };
await page.setViewportSize(viewport);
await page.goto('/');
const header = page.locator('.site-header');
const box = await header.boundingBox();
expect(box).toBeTruthy();
expect(box!.height).toBeLessThan(viewport.height * 0.25);
await expect
.poll(async () => header.evaluate((element) => getComputedStyle(element).position))
.not.toBe('sticky');
await page.locator('.nav-toggle').click();
await expect(page.locator('.nav-toggle')).toHaveAttribute('aria-expanded', 'true');
expect(await header.evaluate((element) => getComputedStyle(element).position)).not.toBe(
'sticky',
);
for (const width of [768, 820, 1024, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 });
await page.goto('/');
expect(
await header.evaluate((element) => getComputedStyle(element).position),
`header must not be sticky at ${width}px`,
).not.toBe('sticky');
}
});
test('compact services submenu stays keyboard accessible at 1024px', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'compact submenu applies from md up');
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('/');
const servicesParent = page.locator(
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`,
);
const firstChild = page.locator(
`.primary-nav > li > .submenu a[href="${pagePath('servicesSoftware', 'de')}"]`,
);
await servicesParent.focus();
await expect(firstChild).toBeVisible();
await page.keyboard.press('Tab');
await expect(firstChild).toBeFocused();
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('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
page,
}) => {
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
await trigger.focus();
await page.keyboard.press('Control+k');
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(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});

81
e2e/layout.e2e.ts Normal file
View File

@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { SITE_CONFIG } from '../src/app/core/content/site-config';
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'),
pagePath('contact', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
];
const VIEWPORTS = [320, 768, 1024, 1440] as const;
test.describe('layout and crawlability', () => {
test('does not overflow horizontally on checked routes', async ({ page }) => {
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: 900 });
for (const path of CHECKED) {
await page.goto(path);
await expectNoOverflow(page, path, width);
}
}
});
test('same-origin links and crawl files respond below 400', async ({ page, request }) => {
const seen = new Set<string>();
const queue = [pagePath('home', 'de'), pagePath('home', 'en')];
while (queue.length > 0) {
const path = queue.shift() as string;
if (seen.has(path)) {
continue;
}
seen.add(path);
await page.goto(path);
const { urls, baseURI } = await page.evaluate(() => {
const values = [
...Array.from(document.querySelectorAll('a[href]'), (node) => node.getAttribute('href')),
...Array.from(document.querySelectorAll('[src]'), (node) => node.getAttribute('src')),
];
return {
baseURI: document.baseURI,
urls: values.filter(
(value): value is string => typeof value === 'string' && value.length > 0,
),
};
});
for (const raw of urls) {
if (raw.startsWith('mailto:') || raw.startsWith('tel:') || raw.startsWith('#')) {
continue;
}
const resolved = new URL(raw, baseURI);
if (resolved.origin !== new URL(baseURI).origin) {
continue;
}
const next = `${resolved.pathname}${resolved.search}`;
const response = await request.get(next);
expect(response.status(), `${raw} from ${path}`).toBeLessThan(400);
if (!seen.has(next) && !next.includes('.')) {
queue.push(next);
}
}
}
for (const asset of ['/robots.txt', '/sitemap.xml', '/llms.txt', SITE_CONFIG.cvAssetPath]) {
const response = await request.get(asset);
expect(response.status(), asset).toBe(200);
}
});
});

119
e2e/seo.e2e.ts Normal file
View File

@@ -0,0 +1,119 @@
import { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { expectHead, jsonLdGraph, pagePath } from './helpers';
async function ensurePrimaryNavOpen(page: Page): Promise<void> {
const toggle = page.locator('.nav-toggle');
if ((await toggle.isVisible()) && (await toggle.getAttribute('aria-expanded')) === 'false') {
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
}
test.describe('server-rendered metadata', () => {
test('German and English Home include the full head contract', async ({ request, page }) => {
await expectHead(request, 'home', 'de');
await expectHead(request, 'home', 'en');
await page.goto('/');
const graph = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
const types = graph['@graph'].map((node) => node['@type']);
expect(types).toContain('Person');
expect(types).toContain('ProfessionalService');
await page.goto('/en');
const english = (await jsonLdGraph(page)) as { '@graph': Array<{ '@type': string }> };
expect(english['@graph'].map((node) => node['@type'])).toEqual(
expect.arrayContaining(['Person', 'ProfessionalService']),
);
});
test('projects, AI service, contact, legal and 404 keep locale metadata', async ({ request }) => {
for (const locale of ['de', 'en'] as const) {
await expectHead(request, 'projects', locale);
await expectHead(request, 'servicesAi', locale);
await expectHead(request, 'contact', locale);
await expectHead(request, 'imprint', locale);
await expectHead(request, 'notFound', locale);
}
});
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);
await expectHead(request, 'notFound', 'de');
await expectHead(request, 'notFound', 'en');
});
test('client navigation does not duplicate head tags', async ({ page }) => {
await page.goto('/');
await ensurePrimaryNavOpen(page);
await page.locator('a.contact-cta').first().waitFor();
const assertUnique = async (canonical: string, description: string) => {
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="de-DE"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="en"]')).toHaveCount(1);
await expect(page.locator('link[rel="alternate"][hreflang="x-default"]')).toHaveCount(1);
await expect(page.locator('meta[name="description"]')).toHaveCount(1);
await expect(page.locator('meta[name="robots"]')).toHaveCount(1);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', canonical);
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
description,
);
};
await ensurePrimaryNavOpen(page);
await page
.locator(`a[href="${pagePath('projects', 'de')}"]`)
.first()
.click();
await page.waitForURL('**/projekte');
await assertUnique(
'https://antoniolede.de/projekte',
SITE_CONTENT_DATA.de.pages.projects.description,
);
await ensurePrimaryNavOpen(page);
const servicesParent = page.locator(
`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`,
);
const servicesAiLink = page
.locator(`.primary-nav a[href="${pagePath('servicesAi', 'de')}"]`)
.first();
if (!(await servicesAiLink.isVisible())) {
await servicesParent.hover();
await expect(servicesAiLink).toBeVisible();
}
await servicesAiLink.click();
await page.waitForURL('**/leistungen/ai-integration');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://antoniolede.de/leistungen/ai-integration',
);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
});
});

66
e2e/systems-map.e2e.ts Normal file
View File

@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test';
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('services', 'de'));
for (const width of [390, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const list = page.locator('.systems-map-list');
await expect(list).toBeVisible();
await expect(list.locator('.systems-map-cards li')).toHaveCount(8);
const figure = page.locator('.systems-map-figure');
if (width < 1024) {
await expect(figure).toBeHidden();
} else {
await expect(figure).toBeVisible();
}
}
});
test('keeps SVG labels inside their shapes at 1024 and 1440', async ({ page }) => {
await page.goto(pagePath('services', 'de'));
for (const width of [1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
const overflow = await page.evaluate(() => {
const nodes = Array.from(document.querySelectorAll<SVGGElement>('.systems-map-node'));
return nodes.flatMap((node) => {
const shape = node.querySelector('circle, rect');
const text = node.querySelector('text');
if (!shape || !text) {
return [`${node.getAttribute('aria-label') ?? 'node'} missing shape or text`];
}
const shapeBox = (shape as SVGGraphicsElement).getBBox();
const textBox = (text as SVGGraphicsElement).getBBox();
const fits =
textBox.x >= shapeBox.x - 0.5 &&
textBox.y >= shapeBox.y - 0.5 &&
textBox.x + textBox.width <= shapeBox.x + shapeBox.width + 0.5 &&
textBox.y + textBox.height <= shapeBox.y + shapeBox.height + 0.5;
return fits ? [] : [node.getAttribute('aria-label') ?? 'unnamed node'];
});
});
expect(overflow, `labels overflow at ${width}px`).toEqual([]);
}
});
test('every SVG node points at a real route', async ({ page, request }) => {
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 1440, height: 900 });
const hrefs = await page
.locator('.systems-map-node')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute('href') ?? ''));
expect(hrefs.length).toBeGreaterThan(0);
for (const href of hrefs) {
const response = await request.get(href);
expect(response.status(), href).toBe(200);
}
});
});

145
e2e/terminal.e2e.ts Normal file
View 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();
});
});

62
eslint.config.js Normal file
View File

@@ -0,0 +1,62 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: [
'dist/**',
'.angular/**',
'node_modules/**',
'coverage/**',
'playwright-report/**',
'test-results/**',
'.lighthouseci/**',
],
},
{
files: ['**/*.ts'],
ignores: ['e2e/**/*.ts', 'playwright.config.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
},
{
files: ['e2e/**/*.ts', 'playwright.config.ts'],
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
parserOptions: {
project: './tsconfig.e2e.json',
tsconfigRootDir: __dirname,
},
},
},
prettier,
);

33
lighthouserc.json Normal file
View File

@@ -0,0 +1,33 @@
{
"ci": {
"collect": {
"startServerCommand": "npm run serve:ssr",
"startServerReadyPattern": "Node Express server listening",
"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"
],
"numberOfRuns": 3,
"settings": {
"preset": "desktop",
"chromeFlags": "--no-sandbox --headless=new"
}
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"categories:accessibility": ["error", { "minScore": 1 }],
"categories:best-practices": ["error", { "minScore": 1 }],
"categories:seo": ["error", { "minScore": 1 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": ".lighthouseci"
}
}
}

4760
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,18 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs" "test:ci": "ng test --watch=false",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"serve:ssr": "node dist/Portfolio/server/server.mjs",
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs",
"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",
"cv:link": "node scripts/add-cv-pitch-link.mjs"
}, },
"prettier": { "prettier": {
"printWidth": 100, "printWidth": 100,
@@ -40,10 +51,21 @@
"@angular/build": "^21.0.5", "@angular/build": "^21.0.5",
"@angular/cli": "^21.0.5", "@angular/cli": "^21.0.5",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@axe-core/playwright": "^4.13.0",
"@eslint/js": "^9.39.5",
"@lhci/cli": "^0.15.1",
"@playwright/test": "1.62.1",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/node": "^20.17.19", "@types/node": "^20.17.19",
"angular-eslint": "^21.4.0",
"axe-core": "^4.13.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"pdf-lib": "^1.17.1",
"prettier": "^3.9.6",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "^8.68.0",
"vitest": "^4.0.8" "vitest": "^4.0.8"
} }
} }

40
playwright.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.e2e.ts',
forbidOnly: !!process.env['CI'],
retries: 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run build && npm run serve:ssr',
url: 'http://127.0.0.1:4173',
env: { PORT: '4173' },
timeout: 300_000,
reuseExistingServer: !process.env['CI'],
},
projects: [
{
name: 'desktop',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'mobile',
use: {
...devices['Desktop Chrome'],
viewport: { width: 390, height: 844 },
hasTouch: true,
isMobile: true,
},
},
],
});

6
public/db/mariadb.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>mariadb</title>
<path d="M29.942 6.518c-0.597 0.293-1.3 0.465-2.042 0.465-0.017 0-0.034-0-0.051-0l0.003 0c-0.543 0-1.064 0.096-1.546 0.271l0.032-0.010c-1.052 0.391-1.916 1.082-2.513 1.969l-0.012 0.018c-0.495 0.693-1.011 1.505-1.487 2.343l-0.074 0.142c-0.382 0.63-0.759 1.169-1.168 1.681l0.024-0.031c-0.701 0.866-1.6 1.545-2.63 1.971l-0.044 0.016c-1.369 0.617-3.070 1.245-4.818 1.767l-0.308 0.079c-1.341 0.441-2.665 0.922-2.958 1.080-1.109 0.603-2.030 1.418-2.737 2.398l-0.016 0.024c-1.253 1.65-1.215 1.641-3.801 1.183q-0.421-0.051-0.844-0.079c-0.12-0.026-0.258-0.041-0.399-0.041-0.483 0-0.925 0.173-1.268 0.461l0.003-0.003-0.284 0.269 0.221 0.11c0.268 0.167 0.496 0.331 0.714 0.508l-0.013-0.010c0.202 0.167 0.426 0.328 0.661 0.473l0.026 0.015c0.089 0.041 0.164 0.084 0.236 0.131l-0.007-0.004c-0.078 0.169-0.173 0.314-0.287 0.443l0.002-0.002c-0.544 0.726-0.741 1.088-0.717 1.31 0.024 0.205 0.040 0.212 0.537 0.212 0.027 0.001 0.059 0.001 0.091 0.001 0.533 0 1.043-0.096 1.515-0.271l-0.030 0.010c1.352-0.551 2.496-1.138 3.582-1.809l-0.103 0.059c0.704-0.485 1.508-0.922 2.358-1.271l0.086-0.031c0.102-0.025 0.442-0.087 0.742-0.142 0.42-0.055 0.906-0.087 1.4-0.087 0.647 0 1.282 0.054 1.899 0.159l-0.067-0.009c0.135 0.016 0.466 0.056 0.75 0.080 0.208 0.014 0.402 0.048 0.587 0.1l-0.020-0.005c0.033 0.015 0.592 0.046 1.247 0.070 1.167 0.032 1.38 0.009 1.625-0.236 0.256-0.353 0.467-0.761 0.613-1.199l0.009-0.032c0.261-0.804 0.521-1.151 0.457-0.615-0.1 1.117-0.418 2.14-0.912 3.055l0.021-0.042c-0.351 0.662-0.738 1.234-1.179 1.758l0.012-0.015c-0.402 0.434-0.394 0.45 0.11 0.394 0.991-0.155 1.876-0.516 2.641-1.039l-0.022 0.014c1.225-0.975 2.167-2.255 2.717-3.727l0.019-0.059c0.134-0.337 0.275-0.275 0.229 0.104-0.016 0.117-0.047 0.394-0.071 0.622l-0.039 0.41 0.441-0.252c1.245-0.867 2.178-2.107 2.644-3.555l0.014-0.049c0.473-1.33 0.936-2.995 1.294-4.699l0.047-0.269c0.106-0.524 0.223-0.961 0.363-1.387l-0.024 0.085c0.092-0.425 0.305-0.791 0.599-1.071l0.001-0.001c0.37-0.353 0.768-0.684 1.189-0.987l0.033-0.023c0.63-0.358 1.134-0.87 1.473-1.486l0.010-0.020c0.16-0.307 0.254-0.671 0.254-1.057 0-0.227-0.033-0.447-0.093-0.655l0.004 0.016c-0.165-0.252-0.355-0.245-0.954 0.008z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

2
public/db/mysql.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

4
public/db/postgresql.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

12
public/devops/azure.svg Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#ffffff">
<path d="M7.47 12.412l3.348-.592.031-.007-1.722-2.049a291.474 291.474 0 01-1.723-2.058c0-.01 1.779-4.909 1.789-4.926a788.95 788.95 0 012.934 5.066l2.95 5.115.023.039-10.948-.001 3.317-.587zM.9 11.788c0-.003.811-1.412 1.803-3.131L4.507 5.53l2.102-1.764C7.765 2.797 8.714 2 8.717 2a.37.37 0 01-.033.085L6.4 6.981 4.16 11.789l-1.63.002c-.897.001-1.63 0-1.63-.003z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 622 B

6
public/devops/devops.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>azuredevops</title>
<path d="M30.996 6.536v18.308l-7.189 6.123-11.625-3.82v3.819l-7.47-9.267 18.816 2.247v-16.175zM1.004 12.097l2.808-3.708 10.503-4.269v-3.089l9.21 6.739-18.815 3.653v10.278l-3.706-1.070z"></path>
</svg>

After

Width:  |  Height:  |  Size: 473 B

6
public/devops/docker.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>docker</title>
<path d="M30.679 13.367c-0.613-0.404-1.366-0.645-2.175-0.645-0.093 0-0.185 0.003-0.276 0.009l0.012-0.001c-0.446 0.001-0.882 0.041-1.306 0.115l0.046-0.007c-0.235-1.357-1.025-2.495-2.122-3.191l-0.019-0.011-0.429-0.248-0.282 0.408c-0.336 0.512-0.599 1.108-0.756 1.745l-0.008 0.039c-0.089 0.324-0.14 0.696-0.14 1.080 0 0.831 0.24 1.605 0.654 2.258l-0.010-0.017c-0.637 0.299-1.381 0.488-2.164 0.524l-0.013 0h-19.729c-0.516 0.001-0.934 0.418-0.936 0.933v0c-0.001 0.056-0.001 0.123-0.001 0.19 0 1.751 0.317 3.428 0.897 4.977l-0.032-0.098c0.558 1.633 1.612 2.975 2.98 3.881l0.028 0.017c1.744 0.903 3.808 1.432 5.995 1.432 0.207 0 0.412-0.005 0.617-0.014l-0.029 0.001c0.018 0 0.039 0 0.060 0 1.272 0 2.515-0.121 3.72-0.352l-0.123 0.020c1.795-0.336 3.395-0.939 4.845-1.773l-0.074 0.039c1.257-0.735 2.331-1.621 3.245-2.652l0.012-0.014c1.325-1.56 2.403-3.381 3.145-5.365l0.041-0.125h0.276c0.055 0.002 0.119 0.004 0.183 0.004 1.226 0 2.34-0.481 3.163-1.265l-0.002 0.002c0.377-0.358 0.676-0.793 0.873-1.281l0.009-0.024 0.122-0.359zM3.79 14.849h2.646c0.127-0.001 0.23-0.104 0.23-0.231 0 0 0 0 0 0v0-2.355c0-0 0-0.001 0-0.001 0-0.127-0.103-0.23-0.23-0.231h-2.646c-0.127 0.001-0.23 0.104-0.23 0.231v0 2.356c0 0 0 0 0 0.001 0 0.127 0.103 0.23 0.23 0.23 0 0 0 0 0.001 0h-0zM7.434 14.849h2.644c0.127 0 0.231-0.103 0.231-0.231 0 0 0 0 0-0v0-2.355c0-0 0-0.001 0-0.001 0-0.127-0.103-0.23-0.23-0.231h-2.646c-0 0-0 0-0 0-0.128 0-0.232 0.104-0.232 0.232 0 0 0 0 0 0v0 2.355c0.001 0.128 0.104 0.231 0.232 0.231v0zM11.132 14.849h2.646c0.127-0.001 0.23-0.104 0.23-0.231 0 0 0 0 0 0v0-2.355c0-0 0-0.001 0-0.001 0-0.127-0.103-0.23-0.23-0.231h-2.646c-0.127 0.001-0.23 0.104-0.23 0.231v0 2.356c0 0.127 0.103 0.231 0.231 0.231v0zM14.788 14.849h2.643c0 0 0 0 0 0 0.128 0 0.231-0.103 0.232-0.231v-2.355c0-0 0-0 0-0 0-0.128-0.104-0.232-0.232-0.232-0 0-0 0-0 0h-2.643c-0.127 0-0.231 0.103-0.231 0.231v0 2.356c0 0 0 0 0 0.001 0 0.127 0.103 0.23 0.23 0.23 0 0 0 0 0.001 0h-0zM7.434 11.461h2.644c0.128-0.001 0.231-0.104 0.231-0.232 0 0 0 0 0 0v0-2.355c0 0 0 0 0-0 0-0.127-0.103-0.231-0.231-0.231h-2.644c-0.128 0-0.231 0.103-0.232 0.231v2.355c0.001 0.128 0.104 0.231 0.232 0.232h0zM11.132 11.461h2.646c0.127-0.001 0.23-0.105 0.23-0.232v0-2.355c0 0 0 0 0-0 0-0.127-0.103-0.231-0.231-0.231h-2.643c-0.127 0-0.231 0.103-0.231 0.231 0 0 0 0 0 0v0 2.355c0.001 0.128 0.103 0.231 0.231 0.232h0zM14.788 11.461h2.643c0.128-0.001 0.231-0.104 0.232-0.232v-2.355c-0.001-0.128-0.104-0.231-0.232-0.231h-2.643c-0.127 0-0.231 0.103-0.231 0.231 0 0 0 0 0 0v0 2.355c0 0.128 0.103 0.231 0.231 0.232h0zM14.788 8.073h2.643c0 0 0 0 0 0 0.128 0 0.232-0.104 0.232-0.232 0-0 0-0 0-0v0-2.356c-0.001-0.128-0.104-0.231-0.232-0.231 0 0-0 0-0 0h-2.643c-0.127 0-0.231 0.103-0.231 0.231v0 2.356c0 0 0 0 0 0.001 0 0.127 0.103 0.23 0.23 0.23 0 0 0 0 0.001 0h-0zM18.475 14.849h2.644c0 0 0 0 0 0 0.128 0 0.231-0.103 0.232-0.231v-2.355c0-0 0-0 0-0 0-0.128-0.104-0.232-0.232-0.232-0 0-0 0-0 0h-2.644c0 0 0 0-0 0-0.128 0-0.231 0.103-0.231 0.231v2.356c0 0.127 0.103 0.231 0.231 0.231v0z"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

4
public/devops/gitlab.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.97436 0.341889C2.90406 0.130995 2.70297 -0.00815973 2.48083 0.000371457C2.25869 0.00890261 2.06886 0.163071 2.01495 0.378735L0.0149454 8.37874C-0.0309875 8.56247 0.0308284 8.75638 0.174621 8.87963L7.17462 14.8796C7.36187 15.0401 7.63817 15.0401 7.82541 14.8796L14.8254 8.87963C14.9692 8.75638 15.031 8.56247 14.9851 8.37874L12.9851 0.378735C12.9312 0.163071 12.7413 0.00890261 12.5192 0.000371457C12.2971 -0.00815973 12.096 0.130995 12.0257 0.341889L10.1396 6H4.8604L2.97436 0.341889Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" fill="#ffffff"><path fill="#ffffff" d="M0 0v24h24V0H0zm4.602 4.025h2.244c.509 0 .716.215.716.717v5.64h8.883v-5.64c0-.509.215-.717.717-.717h2.229c.5 0 .71.23.724.717v14.516c0 .509-.215.717-.717.717h-2.23c-.51 0-.717-.215-.717-.717v-5.735H7.562v5.735c0 .516-.215.717-.716.717H4.602c-.51 0-.717-.208-.717-.717V4.742c0-.509.207-.717.717-.717z"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#ffffff" fill-rule="evenodd" d="M5.374 15c-.335 0-.66-.153-.874-.431l-3.254-4.172a1.15 1.15 0 01-.214-.978l1.165-5.207c.074-.345.298-.623.605-.776l4.715-2.32c.15-.078.317-.116.485-.116.168 0 .335.038.485.115L13.2 3.426c.308.153.532.432.606.777l1.165 5.207c.074.345 0 .7-.214.978L11.5 14.566c-.214.268-.54.434-.875.434h-5.25zm7.718-5.835l.031.008a.308.308 0 01.26.371.306.306 0 01-.396.223h-.004l-.003-.001-.003-.001-.03-.007-.05-.01a2.548 2.548 0 01-.274-.106 2.87 2.87 0 00-.533-.156.242.242 0 00-.171.063 4.76 4.76 0 00-.131-.023 3.972 3.972 0 01-1.764 2.212c.015.042.032.083.051.123a.239.239 0 00-.023.18c.074.17.165.332.271.484.06.078.114.16.164.244l.028.057.012.025a.306.306 0 01-.381.44.307.307 0 01-.172-.18 2.608 2.608 0 00-.01-.02l-.028-.058a2.545 2.545 0 01-.089-.28 2.835 2.835 0 00-.21-.512.242.242 0 00-.156-.095 5.926 5.926 0 01-.03-.053l-.035-.064a3.97 3.97 0 01-2.824-.007l-.069.125a.249.249 0 00-.132.064c-.104.17-.184.355-.237.548a2.525 2.525 0 01-.088.28l-.025.05-.013.027v.001a.307.307 0 11-.553-.261l.014-.03.026-.052c.05-.085.104-.166.164-.244.108-.156.2-.322.277-.496a.302.302 0 00-.028-.173l.056-.133A3.972 3.972 0 014.22 9.532l-.134.023a.34.34 0 00-.176-.062 2.872 2.872 0 00-.533.156c-.09.04-.181.075-.274.105l-.05.011-.03.007H3.02l-.002.002h-.005a.308.308 0 01-.397-.349.306.306 0 01.261-.245l.004-.001h.003l.006-.002c.024-.006.054-.014.076-.018.097-.013.195-.021.293-.023.186-.013.37-.043.549-.09a.422.422 0 00.131-.133l.128-.037a3.938 3.938 0 01.624-2.752l-.097-.087a.338.338 0 00-.062-.176 2.854 2.854 0 00-.455-.319 2.557 2.557 0 01-.254-.148 1.129 1.129 0 01-.063-.05l-.004-.004a.323.323 0 01-.076-.45.295.295 0 01.244-.107.365.365 0 01.213.08l.022.017c.016.013.034.026.046.037.071.067.139.139.202.213.125.137.263.262.412.372.056.03.121.036.182.018l.11.078a3.938 3.938 0 012.552-1.224l.007-.129a.332.332 0 00.1-.157 2.844 2.844 0 00-.034-.554 2.555 2.555 0 01-.042-.29v-.053-.025-.004-.004A.306.306 0 018 2.82a.308.308 0 01.306.337v.087a2.53 2.53 0 01-.041.29 2.85 2.85 0 00-.035.553.242.242 0 00.1.153v.007l.007.13c.967.087 1.87.522 2.54 1.223l.116-.083a.34.34 0 00.186-.02c.149-.11.287-.235.412-.373a2.53 2.53 0 01.202-.213l.051-.04.017-.014a.308.308 0 01.472.388.307.307 0 01-.09.09c-.008.005-.017.012-.025.02l-.043.033a2.549 2.549 0 01-.254.148 2.865 2.865 0 00-.455.32.24.24 0 00-.058.172 4.458 4.458 0 01-.05.044l-.058.053c.542.806.769 1.783.637 2.745l.123.036c.031.056.077.101.132.132.18.048.364.078.55.09.097.003.195.01.292.024l.058.013zm-2.875-3.1l-1.308.925-.004-.002a.27.27 0 01-.43-.205v-.001l-.091-1.598a3.183 3.183 0 011.833.882zM7.754 7.818h.492l.306.381-.11.476L8 8.886l-.443-.213-.11-.475.307-.381zM7.29 5.24c.107-.024.216-.043.326-.056l-.09 1.6-.008.004a.268.268 0 01-.293.256.27.27 0 01-.135-.05l-.002.001-1.316-.93c.419-.41.945-.696 1.518-.825zM5.296 6.663l1.201 1.071-.001.007a.269.269 0 01-.106.462l-.001.005-1.54.443a3.134 3.134 0 01.447-1.988zm1.608 2.846l-.612 1.474a3.16 3.16 0 01-1.27-1.586L6.6 9.13l.003.003a.265.265 0 01.18.029.27.27 0 01.117.341l.004.006zm1.806 1.896c-.572.13-1.17.1-1.726-.088l.777-1.4h.001a.27.27 0 01.475-.001h.006l.779 1.402a3.286 3.286 0 01-.312.087zm1.004-.416L9.096 9.5l.001-.003a.269.269 0 01.296-.37l.003-.004 1.593.269a3.147 3.147 0 01-1.275 1.597zm1.442-2.343L9.61 8.201l-.002-.006a.27.27 0 01-.185-.343.27.27 0 01.08-.12L9.5 7.73l1.195-1.067c.366.594.527 1.29.46 1.983z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

1
public/devops/netcup.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 24 24" fill="#ffffff"><path fill="#ffffff" d="M5.25 0A5.239 5.239 0 0 0 0 5.25v13.5A5.239 5.239 0 0 0 5.25 24h13.5A5.239 5.239 0 0 0 24 18.75V5.25A5.239 5.239 0 0 0 18.75 0zm-.045 5.102h9.482c1.745 0 2.631.907 2.631 2.753v8.352h1.477v2.691h-4.666V8.58c0-.514-.298-.785-.889-.785H9.873v11.103H6.682V7.795H5.205z"/></svg>

After

Width:  |  Height:  |  Size: 398 B

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2H13.5C14.3284 2 15 2.67157 15 3.5V13.5C15 14.3284 14.3284 15 13.5 15H1.5C0.671573 15 0 14.3284 0 13.5V3.5C0 2.67157 0.671573 2 1.5 2H3V0H4V2H11V0H12V2ZM6 8H3V7H6V8ZM12 7H9V8H12V7ZM6 11H3V10H6V11ZM9 11H12V10H9V11Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>windows [#174]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-60.000000, -7439.000000)" fill="#ffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M13.1458647,7289.43426 C13.1508772,7291.43316 13.1568922,7294.82929 13.1619048,7297.46884 C16.7759398,7297.95757 20.3899749,7298.4613 23.997995,7299 C23.997995,7295.84873 24.002005,7292.71146 23.997995,7289.71311 C20.3809524,7289.71311 16.7649123,7289.43426 13.1458647,7289.43426 M4,7289.43526 L4,7296.22153 C6.72581454,7296.58933 9.45162907,7296.94113 12.1724311,7297.34291 C12.1774436,7294.71736 12.1704261,7292.0908 12.1704261,7289.46524 C9.44661654,7289.47024 6.72380952,7289.42627 4,7289.43526 M4,7281.84344 L4,7288.61071 C6.72581454,7288.61771 9.45162907,7288.57673 12.1774436,7288.57973 C12.1754386,7285.96017 12.1754386,7283.34361 12.1724311,7280.72405 C9.44461153,7281.06486 6.71679198,7281.42567 4,7281.84344 M24,7288.47179 C20.3879699,7288.48578 16.7759398,7288.54075 13.1619048,7288.55175 C13.1598997,7285.88921 13.1598997,7283.22967 13.1619048,7280.56914 C16.7689223,7280.01844 20.3839599,7279.50072 23.997995,7279 C24,7282.15826 23.997995,7285.31353 24,7288.47179" id="windows-[#174]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M6.573 2.432c-1.453 0.74-1.453 0.854-0.094 2.375 7.536 8.391 9.339 10.375 9.474 10.375 0.188 0.021 10.63-11.391 10.745-11.734 0.047-0.094-0.276-0.417-0.693-0.714-0.552-0.417-1.151-0.578-2.281-0.625-2.12-0.135-2.859 0.323-5.49 3.276-1.198 1.333-2.214 2.443-2.214 2.443-0.021 0-1.010-1.083-2.188-2.396s-2.536-2.63-2.995-2.885c-1.063-0.599-3.229-0.646-4.271-0.115zM1.729 5.823c-1.13 0.438-1.729 0.854-1.729 1.172 0 0.161 1.776 2.24 3.922 4.615 2.167 2.375 3.917 4.359 3.917 4.401 0 0.047-1.776 2.031-3.922 4.406-2.167 2.396-3.896 4.495-3.87 4.677 0.115 0.625 2.005 1.411 3.385 1.411 2.24-0.026 2.745-0.417 7.474-5.604 2.375-2.604 4.307-4.818 4.307-4.891 0-0.089-1.911-2.255-4.26-4.839-3.068-3.344-4.568-4.844-5.281-5.167-1.083-0.531-2.833-0.62-3.943-0.182zM26.354 5.984c-0.672 0.344-2.354 2.005-5.26 5.188-2.349 2.583-4.266 4.75-4.266 4.839 0 0.094 1.938 2.286 4.313 4.891 4.724 5.188 5.234 5.578 7.469 5.604 1.385 0 3.276-0.786 3.391-1.411 0.021-0.208-1.708-2.281-3.875-4.656-2.141-2.37-3.917-4.38-3.917-4.427 0-0.042 1.776-2.052 3.917-4.427 2.167-2.37 3.896-4.448 3.875-4.63-0.115-0.599-1.823-1.292-3.297-1.385-1.063-0.047-1.615 0.047-2.349 0.417zM10.604 22.63c-2.859 3.161-5.208 5.833-5.255 5.948-0.047 0.094 0.276 0.417 0.693 0.714 0.552 0.417 1.151 0.578 2.281 0.625 2.099 0.135 2.88-0.349 5.531-3.344 1.156-1.292 2.146-2.375 2.167-2.375 0.026 0 1.016 1.083 2.193 2.396 2.698 3 3.411 3.438 5.51 3.323 1.13-0.047 1.729-0.208 2.281-0.625 0.417-0.297 0.74-0.62 0.693-0.714-0.115-0.344-10.563-11.76-10.745-11.734-0.094 0-2.49 2.604-5.349 5.786z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

2
public/iac/ansible.svg Normal file
View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Ansible icon</title><path d="M10.617 11.473l4.686 3.695-3.102-7.662zM12 0C5.371 0 0 5.371 0 12s5.371 12 12 12 12-5.371 12-12S18.629 0 12 0zm5.797 17.305c-.011.471-.403.842-.875.83-.236 0-.416-.09-.664-.293l-6.19-5-2.079 5.203H6.191L11.438 5.44c.124-.314.427-.52.764-.506.326-.014.63.189.742.506l4.774 11.494c.045.111.08.234.08.348-.001.009-.001.009-.001.023z"/></svg>

After

Width:  |  Height:  |  Size: 609 B

4
public/iac/terraform.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M11.041 5.688l9.912 5.031v10.073l-9.912-5.037zM22.041 10.719v10.073l9.917-5.037v-10.067zM0.047 0.068v10.068l9.912 5.036v-10.068zM11.041 26.921l9.912 5.037v-10.063l-9.912-5.036z"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

53
public/llms.txt Normal file
View File

@@ -0,0 +1,53 @@
# 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.
## 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
## Services
- 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
- https://antoniolede.de/projekte
- https://antoniolede.de/en/projects
- https://antoniolede.de/projekte#innofocus
- 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#hdi
- https://antoniolede.de/en/projects#hdi
## Contact, stack and legal
- https://antoniolede.de/kontakt
- https://antoniolede.de/en/contact
- https://antoniolede.de/stack
- https://antoniolede.de/en/stack
- https://antoniolede.de/impressum
- https://antoniolede.de/en/legal-notice
- https://antoniolede.de/datenschutz
- https://antoniolede.de/en/privacy
## Languages
German URLs are unprefixed. English URLs live under `/en`. Each public page exists in both locales.

33
public/os/arch.svg Normal file
View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
width="128" height="128" version="1.1">
<title>Arch Logo</title>
<desc>This is shape (source) for Clarity vector icon theme for gtk</desc>
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:title>Arch Logo</dc:title>
<dc:description>This is shape (source) for Clarity vector icon theme for gtk</dc:description>
<dc:creator>
<cc:Agent>
<dc:title>Jakub Jankiewicz</dc:title>
</cc:Agent>
</dc:creator>
<dc:rights>
<cc:Agent>
<dc:title>Jakub Jankiewicz</dc:title>
</cc:Agent>
</dc:rights>
<dc:date>2010</dc:date>
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/"/>
</cc:Work>
</rdf:RDF>
</metadata>
<path fill="#ffffff" d="M 104.50715,93.106041 C 88.620817,63.814747 65.644515,19.806944 63.527825,10.315314 c 0,0 -4.615285,11.632936 -12.844798,29.55467 2.996466,4.643391 10.373351,9.644813 13.072949,12.854838 C 54.434655,49.783544 48.759029,43.931831 48.759029,43.931831 39.182344,64.563041 25.655464,91.759025 10.315318,116.87166 17.635956,113.1318 37.808382,100.77398 52.286979,99.147945 51.798935,92.318635 51.961614,72.968879 63.186594,72.643673 74.411572,72.318466 77.177147,88.741364 75.550339,99.310548 85.961911,101.58699 109.38796,111.83097 117.68468,117.68468 l -8.86611,-16.7481 c 0,0 -12.282401,-8.374053 -18.220253,-12.683025 7.645999,0.162604 12.011193,3.806015 13.908833,4.852486 z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

19
public/os/macos.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>apple [#173]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#ffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

6
public/os/ubuntu.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="-5 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<title>ubuntu</title>
<path d="M16.469 9.375c-1.063-0.594-1.406-1.938-0.813-3 0.406-0.719 1.156-1.094 1.906-1.094 0.375 0 0.75 0.094 1.094 0.281 1.063 0.625 1.406 1.969 0.813 3-0.406 0.719-1.156 1.094-1.906 1.094-0.375 0-0.75-0.094-1.094-0.281zM21.938 15.594h-3.625c-0.125-1.688-0.969-3.188-2.25-4.156-0.219-0.156-0.438-0.313-0.688-0.469-0.813-0.438-1.75-0.688-2.75-0.688-1.031 0-1.969 0.25-2.813 0.719l-2-3.031c1.406-0.844 3.031-1.313 4.813-1.313 0.688 0 1.375 0.063 2.063 0.219-0.25 1.219 0.281 2.5 1.406 3.156 0.438 0.25 0.938 0.375 1.469 0.375 0.719 0 1.406-0.25 1.938-0.719 1.438 1.563 2.344 3.625 2.438 5.906zM7.125 8.438l2 3.031c-1.25 0.969-2.094 2.438-2.188 4.125-0.031 0.125-0.031 0.25-0.031 0.406 0 0.125 0 0.281 0.031 0.406 0.125 1.781 1.063 3.313 2.438 4.281l-1.906 3.094c-1.813-1.188-3.188-3-3.813-5.125 0.875-0.5 1.5-1.469 1.5-2.563s-0.625-2.094-1.563-2.594c0.594-2.063 1.844-3.844 3.531-5.063zM2.188 13.906c1.219 0 2.219 0.969 2.219 2.188s-1 2.219-2.219 2.219-2.188-1-2.188-2.219 0.969-2.188 2.188-2.188zM8.188 24.219l1.906-3.125c0.75 0.375 1.625 0.594 2.531 0.594 1 0 1.938-0.25 2.781-0.719 0.25-0.125 0.469-0.281 0.688-0.469 1.25-0.938 2.094-2.406 2.219-4.094h3.625c-0.094 2.375-1.094 4.531-2.656 6.125-0.469-0.344-1.063-0.531-1.656-0.531-0.531 0-1.031 0.125-1.469 0.375-1 0.594-1.531 1.656-1.469 2.719-0.688 0.156-1.375 0.25-2.063 0.25-1.625 0-3.125-0.406-4.438-1.125zM17.625 22.75c0.75 0 1.5 0.375 1.906 1.094 0.594 1.063 0.219 2.438-0.813 3.031-0.344 0.188-0.719 0.281-1.094 0.281-0.781 0-1.5-0.375-1.906-1.094-0.625-1.063-0.25-2.406 0.813-3.031 0.344-0.188 0.719-0.281 1.094-0.281z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

19
public/os/windows.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>windows [#174]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-60.000000, -7439.000000)" fill="#ffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M13.1458647,7289.43426 C13.1508772,7291.43316 13.1568922,7294.82929 13.1619048,7297.46884 C16.7759398,7297.95757 20.3899749,7298.4613 23.997995,7299 C23.997995,7295.84873 24.002005,7292.71146 23.997995,7289.71311 C20.3809524,7289.71311 16.7649123,7289.43426 13.1458647,7289.43426 M4,7289.43526 L4,7296.22153 C6.72581454,7296.58933 9.45162907,7296.94113 12.1724311,7297.34291 C12.1774436,7294.71736 12.1704261,7292.0908 12.1704261,7289.46524 C9.44661654,7289.47024 6.72380952,7289.42627 4,7289.43526 M4,7281.84344 L4,7288.61071 C6.72581454,7288.61771 9.45162907,7288.57673 12.1774436,7288.57973 C12.1754386,7285.96017 12.1754386,7283.34361 12.1724311,7280.72405 C9.44461153,7281.06486 6.71679198,7281.42567 4,7281.84344 M24,7288.47179 C20.3879699,7288.48578 16.7759398,7288.54075 13.1619048,7288.55175 C13.1598997,7285.88921 13.1598997,7283.22967 13.1619048,7280.56914 C16.7689223,7280.01844 20.3839599,7279.50072 23.997995,7279 C24,7282.15826 23.997995,7285.31353 24,7288.47179" id="windows-[#174]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.50007 4.24744L9.14182 8H5.85833L7.50007 4.24744Z" fill="#ffffff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.36267 0.019238C7.45244 -0.00641268 7.54761 -0.00641268 7.63739 0.019238L14.6374 2.01924C14.8719 2.08626 15.0239 2.31276 14.997 2.55522L13.997 11.5552C13.9782 11.7238 13.8753 11.8714 13.7236 11.9472L7.72363 14.9472C7.58287 15.0176 7.41718 15.0176 7.27642 14.9472L1.27642 11.9472C1.12472 11.8714 1.02181 11.7238 1.00308 11.5552L0.00308494 2.55522C-0.0238546 2.31276 0.128104 2.08626 0.362666 2.01924L7.36267 0.019238ZM7.50007 1.75255L11.4581 10.7996L10.542 11.2004L9.57932 9H5.42083L4.45815 11.2004L3.54199 10.7996L7.50007 1.75255Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><path d="M24 8.77h-2.468v7.565h-1.425V8.77h-2.462V7.53H24zm-6.852 7.565h-4.821V7.53h4.63v1.24h-3.205v2.494h2.953v1.234h-2.953v2.604h3.396zm-6.708 0H8.882L4.78 9.863a2.896 2.896 0 0 1-.258-.51h-.036c.032.189.048.592.048 1.21v5.772H3.157V7.53h1.659l3.965 6.32c.167.261.275.442.323.54h.024c-.04-.233-.06-.629-.06-1.185V7.529h1.372zm-8.703-.693a.868.829 0 0 1-.869.829.868.829 0 0 1-.868-.83.868.829 0 0 1 .868-.828.868.829 0 0 1 .869.829z"/></svg>

After

Width:  |  Height:  |  Size: 679 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 8V7H11V8H10Z" fill="#ffffff"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.5 0.421143L14 4.21281V10.7872L7.5 14.5788L1 10.7872V4.21281L7.5 0.421143ZM6.33156 4C4.49159 4 3 5.49159 3 7.33156V7.66844C3 9.50841 4.49159 11 6.33156 11C6.84877 11 7.35887 10.8796 7.82148 10.6483L8.22361 10.4472L7.77639 9.55279L7.37426 9.75385C7.05052 9.91573 6.69352 10 6.33156 10C5.04387 10 4 8.95613 4 7.66844V7.33156C4 6.04387 5.04387 5 6.33156 5C6.69352 5 7.05052 5.08427 7.37426 5.24615L7.77639 5.44721L8.22361 4.55279L7.82148 4.35172C7.35887 4.12042 6.84877 4 6.33156 4ZM9 5V6H8V7H9V8H8V9H9V10H10V9H11V10H12V9H13V8H12V7H13V6H12V5H11V6H10V5H9Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Elastic icon</title><path d="M20.32 16.3152l-3.9463-.923-1.048-2.0031 5.1615-4.522c1.5305.5798 2.551 2.0476 2.551 3.6903 0 1.6906-1.1146 3.2278-2.7182 3.7578m-2.9667 4.666c-.6147 0-1.217-.2062-1.709-.5845l.7801-4.0648 3.5847.8388c.1139.3094.1714.6396.1714.9835 0 1.559-1.268 2.827-2.8272 2.827m-2.6292-.5844a5.902 5.902 0 0 1-4.8925 2.593c-3.2651 0-5.9215-2.6564-5.9215-5.921 0-.4008.039-.7966.1156-1.1785l5.1509-4.6553 5.2557 2.3956 1.1637 2.2235zM.9636 11.443c0-1.69 1.1145-3.2266 2.7175-3.7577l3.9374.93.9242 1.9747-5.0336 4.5498C1.9815 14.558.9636 13.0885.9636 11.4429m5.6847-8.424a2.8325 2.8325 0 0 1 1.718.5798l-.7861 4.079-3.5876-.847c-.114-.3095-.1714-.6397-.1714-.984 0-1.5592 1.268-2.8278 2.8271-2.8278m2.653.5875c1.1044-1.6082 2.9212-2.5674 4.8692-2.5674 3.2562 0 5.9049 2.6492 5.9049 5.9055 0 .3983-.0373.7789-.1139 1.1607l-5.2819 4.628-5.235-2.3865-1.022-2.1822zm14.662 8.9622c0-2.0052-1.2432-3.7582-3.114-4.4403a6.6742 6.6742 0 0 0 .125-1.2855c0-3.7274-3.0325-6.76-6.7605-6.76-2.1775 0-4.2033 1.0443-5.4761 2.804a3.575 3.575 0 0 0-2.1963-.7516c-1.9786 0-3.5883 1.6097-3.5883 3.5882 0 .4373.078.8557.2244 1.2505-1.86.6712-3.139 2.4653-3.139 4.4579 0 2.0155 1.2493 3.7763 3.1287 4.4536a6.8545 6.8545 0 0 0-.1233 1.285c0 3.7189 3.0252 6.7441 6.7441 6.7441 2.183 0 4.2075-1.0485 5.4732-2.8155.63.4947 1.4004.7662 2.1994.7662 1.9784 0 3.5881-1.609 3.5881-3.5876 0-.4373-.078-.8562-.2243-1.2512 1.86-.6706 3.139-2.4652 3.139-4.4578"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M29.094 1.661c-0.448 1.078-1.021 2.094-1.708 3.031-3.010-3.005-7.089-4.693-11.344-4.693-8.802 0-16.042 7.24-16.042 16.042 0 4.391 1.802 8.594 4.984 11.62l0.589 0.526c2.896 2.438 6.552 3.771 10.333 3.776 8.37 0 15.401-6.552 16-14.896 0.438-4.089-0.766-9.255-2.813-15.401zM7.26 27.755c-0.255 0.323-0.651 0.505-1.063 0.505-0.76 0-1.37-0.615-1.375-1.37 0-0.755 0.625-1.375 1.375-1.375 1.151 0.005 1.792 1.344 1.063 2.24zM29.031 22.953c-3.958 5.271-12.417 3.495-17.833 3.75 0 0-0.964 0.057-1.932 0.214 0 0 0.365-0.151 0.833-0.333 3.807-1.323 5.604-1.578 7.917-2.766 4.354-2.214 8.661-7.057 9.557-12.094-1.661 4.849-6.688 9.010-11.266 10.703-3.135 1.156-8.802 2.281-8.802 2.281l-0.229-0.12c-3.859-1.875-3.974-10.229 3.036-12.927 3.068-1.177 6.010-0.531 9.323-1.318 3.542-0.844 7.641-3.5 9.307-6.964 1.865 5.542 4.109 14.214 0.089 19.573z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Java icon</title><path d="M8.851 18.56s-.917.534.653.714c1.902.218 2.874.187 4.969-.211 0 0 .552.346 1.321.646-4.699 2.013-10.633-.118-6.943-1.149M8.276 15.933s-1.028.761.542.924c2.032.209 3.636.227 6.413-.308 0 0 .384.389.987.602-5.679 1.661-12.007.13-7.942-1.218M13.116 11.475c1.158 1.333-.304 2.533-.304 2.533s2.939-1.518 1.589-3.418c-1.261-1.772-2.228-2.652 3.007-5.688 0-.001-8.216 2.051-4.292 6.573M19.33 20.504s.679.559-.747.991c-2.712.822-11.288 1.069-13.669.033-.856-.373.75-.89 1.254-.998.527-.114.828-.093.828-.093-.953-.671-6.156 1.317-2.643 1.887 9.58 1.553 17.462-.7 14.977-1.82M9.292 13.21s-4.362 1.036-1.544 1.412c1.189.159 3.561.123 5.77-.062 1.806-.152 3.618-.477 3.618-.477s-.637.272-1.098.587c-4.429 1.165-12.986.623-10.522-.568 2.082-1.006 3.776-.892 3.776-.892M17.116 17.584c4.503-2.34 2.421-4.589.968-4.285-.355.074-.515.138-.515.138s.132-.207.385-.297c2.875-1.011 5.086 2.981-.928 4.562 0-.001.07-.062.09-.118M14.401 0s2.494 2.494-2.365 6.33c-3.896 3.077-.888 4.832-.001 6.836-2.274-2.053-3.943-3.858-2.824-5.539 1.644-2.469 6.197-3.665 5.19-7.627M9.734 23.924c4.322.277 10.959-.153 11.116-2.198 0 0-.302.775-3.572 1.391-3.688.694-8.239.613-10.937.168 0-.001.553.457 3.393.639"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>javascript [#155]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-420.000000, -7479.000000)" fill="#ffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M379.328,7337.432 C377.583,7337.432 376.455,7336.6 375.905,7335.512 L375.905,7335.512 L377.435,7334.626 C377.838,7335.284 378.361,7335.767 379.288,7335.767 C380.066,7335.767 380.563,7335.378 380.563,7334.841 C380.563,7334.033 379.485,7333.717 378.724,7333.391 C377.368,7332.814 376.468,7332.089 376.468,7330.558 C376.468,7329.149 377.542,7328.075 379.221,7328.075 C380.415,7328.075 381.275,7328.491 381.892,7329.578 L380.429,7330.518 C380.107,7329.941 379.758,7329.713 379.221,7329.713 C378.67,7329.713 378.321,7330.062 378.321,7330.518 C378.321,7331.082 378.67,7331.31 379.476,7331.659 C381.165,7332.383 382.443,7332.952 382.443,7334.814 C382.443,7336.506 381.114,7337.432 379.328,7337.432 L379.328,7337.432 Z M375,7334.599 C375,7336.546 373.801,7337.575 372.136,7337.575 C370.632,7337.575 369.731,7337 369.288,7336 L369.273,7336 L369.266,7336 L369.262,7336 L370.791,7334.931 C371.086,7335.454 371.352,7335.825 371.996,7335.825 C372.614,7335.825 373,7335.512 373,7334.573 L373,7328 L375,7328 L375,7334.599 Z M364,7339 L384,7339 L384,7319 L364,7319 L364,7339 Z" id="javascript-[#155]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Apache Kafka icon</title><path d="M16.262,13.293c-0.935,0-1.772,0.414-2.346,1.066l-1.47-1.041c0.156-0.43,0.246-0.891,0.246-1.374 c0-0.475-0.086-0.928-0.237-1.352l1.467-1.03c0.574,0.649,1.409,1.06,2.341,1.06c1.725,0,3.129-1.403,3.129-3.129 s-1.404-3.129-3.129-3.129s-3.129,1.403-3.129,3.129c0,0.309,0.047,0.607,0.13,0.889l-1.468,1.03 C11.183,8.653,10.3,8.121,9.294,7.959V6.19c1.417-0.298,2.485-1.557,2.485-3.061C11.779,1.403,10.375,0,8.65,0 S5.522,1.403,5.522,3.129c0,1.484,1.04,2.728,2.429,3.047v1.792c-1.895,0.333-3.341,1.987-3.341,3.976 c0,1.999,1.46,3.659,3.37,3.981v1.892c-1.403,0.308-2.457,1.56-2.457,3.054C5.522,22.597,6.925,24,8.65,24s3.129-1.403,3.129-3.129 c0-1.495-1.054-2.746-2.457-3.054v-1.892c0.966-0.163,1.84-0.671,2.46-1.431l1.48,1.048c-0.082,0.279-0.128,0.574-0.128,0.88 c0,1.725,1.404,3.129,3.129,3.129s3.129-1.403,3.129-3.129S17.987,13.293,16.262,13.293z M16.262,5.977 c0.837,0,1.517,0.681,1.517,1.517s-0.68,1.517-1.517,1.517c-0.836,0-1.517-0.681-1.517-1.517S15.426,5.977,16.262,5.977z M7.133,3.129c0-0.836,0.68-1.517,1.517-1.517s1.517,0.681,1.517,1.517S9.487,4.646,8.65,4.646S7.133,3.965,7.133,3.129z M10.167,20.871c0,0.836-0.68,1.517-1.517,1.517s-1.517-0.681-1.517-1.517s0.68-1.517,1.517-1.517S10.167,20.035,10.167,20.871z M8.65,14.06c-1.167,0-2.116-0.949-2.116-2.116c0-1.167,0.949-2.116,2.116-2.116c1.167,0,2.116,0.949,2.116,2.116 C10.766,13.111,9.817,14.06,8.65,14.06z M16.262,17.939c-0.837,0-1.517-0.681-1.517-1.517c0-0.836,0.68-1.517,1.517-1.517 s1.517,0.681,1.517,1.517C17.779,17.258,17.099,17.939,16.262,17.939z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>TypeScript icon</title><path d="M0 12v12h24V0H0zm19.341-.956c.61.152 1.074.423 1.501.865.221.236.549.666.575.77.008.03-1.036.73-1.668 1.123-.023.015-.115-.084-.217-.236-.31-.45-.633-.644-1.128-.678-.728-.05-1.196.331-1.192.967a.88.88 0 0 0 .102.45c.16.331.458.53 1.39.933 1.719.74 2.454 1.227 2.911 1.92.51.773.625 2.008.278 2.926-.38.998-1.325 1.676-2.655 1.9-.411.073-1.386.062-1.828-.018-.964-.172-1.878-.648-2.442-1.273-.221-.243-.652-.88-.625-.925.011-.016.11-.077.22-.141.108-.061.511-.294.892-.515l.69-.4.145.214c.202.308.643.731.91.872.766.404 1.817.347 2.335-.118a.883.883 0 0 0 .313-.72c0-.278-.035-.4-.18-.61-.186-.266-.567-.49-1.649-.96-1.238-.533-1.771-.864-2.259-1.39a3.165 3.165 0 0 1-.659-1.2c-.091-.339-.114-1.189-.042-1.531.255-1.197 1.158-2.03 2.461-2.278.423-.08 1.406-.05 1.821.053zm-5.634 1.002l.008.983H10.59v8.876H8.38v-8.876H5.258v-.964c0-.534.011-.98.026-.99.012-.016 1.913-.024 4.217-.02l4.195.012z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://antoniolede.de/sitemap.xml

159
public/sitemap.xml Normal file
View File

@@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://antoniolede.de/</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
<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" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
</url>
<url>
<loc>https://antoniolede.de/stack</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
</url>
<url>
<loc>https://antoniolede.de/ueber-mich</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
</url>
<url>
<loc>https://antoniolede.de/kontakt</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
</url>
<url>
<loc>https://antoniolede.de/impressum</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
</url>
<url>
<loc>https://antoniolede.de/datenschutz</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
</url>
<url>
<loc>https://antoniolede.de/en</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
<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" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
</url>
<url>
<loc>https://antoniolede.de/en/stack</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
</url>
<url>
<loc>https://antoniolede.de/en/about</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
</url>
<url>
<loc>https://antoniolede.de/en/contact</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
</url>
<url>
<loc>https://antoniolede.de/en/legal-notice</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
</url>
<url>
<loc>https://antoniolede.de/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
</url>
</urlset>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h32v32h-32zM2.803 28h12v-2h-12zM4.401 9.197h0.401c0.932-0.129 1.599-0.932 1.599-2v-3.197h-1.469v3.333c0 0.531-0.129 0.667-0.531 0.667s-0.667-0.136-0.803-0.401l-0.932 0.803c0.401 0.667 1.068 0.932 1.735 0.796zM11.068 9.197v-1.197h-2.803v-0.803h2.401v-1.197h-2.401v-0.803h2.803v-1.197h-4.265v5.333h4.265zM12.803 9.197h1.463v-4h1.599v-1.197h-4.531v1.197h1.599v4zM7.469 14.265c0-0.667-0.401-1.197-1.068-1.197 0.531-0.136 0.796-0.667 0.796-1.204 0-0.265-0.129-0.667-0.265-0.932-0.401-0.401-0.932-0.531-1.463-0.531h-2.667v5.197h2.667c1.197 0 2-0.531 2-1.333zM4.136 11.599h0.932c0.401 0 0.667 0.136 0.667 0.401 0 0.401-0.265 0.531-0.667 0.531h-0.932zM4.136 14.401v-0.932h1.061c0.537 0 0.803 0.129 0.667 0.395 0 0.272-0.265 0.537-0.667 0.537zM14 10.401l-2 4.667-0.803-1.204c0.667-0.265 1.068-0.932 1.068-1.599 0-0.401-0.129-0.932-0.401-1.197-0.531-0.537-1.197-0.667-1.728-0.667h-2.537v5.197h1.469v-1.599h0.667l1.068 1.599h2.395l0.401-0.932h2l0.401 0.932h1.599l-2.129-5.333h-1.469zM10 12.932h-0.932v-1.197h0.932c0.401 0 0.803 0.129 0.803 0.667 0 0.265-0.272 0.531-0.803 0.531zM15.197 13.599h-1.061l0.531-1.463zM17.735 15.599h1.463v-5.197h-1.463zM23.068 13.197l-2.136-2.796h-1.333v5.197h1.469v-2.796l2.265 2.932h1.197v-5.333h-1.463zM27.197 12.401c-0.667-0.136-0.932-0.265-0.932-0.537 0-0.129 0.136-0.265 0.537-0.265 0.531 0 1.061 0.265 1.463 0.537l0.803-1.068c-0.667-0.537-1.333-0.667-2.136-0.667-1.197 0-2 0.796-2 1.735 0 1.061 0.803 1.333 2 1.599 0.667 0.129 0.932 0.265 0.932 0.531s-0.265 0.401-0.667 0.401c-0.667 0-1.197-0.265-1.728-0.667l-0.803 0.932c0.667 0.537 1.599 0.803 2.401 0.803 1.333 0 2.129-0.667 2.129-1.735 0-0.932-0.796-1.333-2-1.599z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

6
public/tools/office.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>microsoft_office</title>
<rect width="24" height="24" fill="none"/>
<path fill="#ffffff" d="M3,18l4-1.25V7l7-2V19.5L3.5,18.25,14,22l6-1.25V3.5L14,2,3,5.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

19
public/tools/vs.svg Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>visual_studio [#145]</title>
<desc>Created with Sketch.</desc>
<defs>
</defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-420.000000, -7519.000000)" fill="#ffffff">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M373.999488,7368.989 L378.758614,7365.514 L378.760661,7372.491 L373.999488,7368.989 Z M366.382635,7371.095 L366.379563,7366.915 L368.761173,7368.991 L366.382635,7371.095 Z M383.998976,7360.865 L379.233707,7359 L370.915476,7366.911 L365.906517,7363.187 L364.002048,7364.123 L364,7373.888 L365.896278,7374.817 L370.901142,7371.096 L379.238827,7379 L384,7377.142 L383.998976,7360.865 Z" id="visual_studio-[#145]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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.');
}

14
src/_breakpoints.scss Normal file
View File

@@ -0,0 +1,14 @@
@use 'sass:map';
$breakpoints: (
sm: 40rem,
md: 48rem,
lg: 64rem,
xl: 80rem,
);
@mixin respond-to($name) {
@media (min-width: map.get($breakpoints, $name)) {
@content;
}
}

View File

@@ -4,9 +4,7 @@ import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server'; import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = { const serverConfig: ApplicationConfig = {
providers: [ providers: [provideServerRendering(withRoutes(serverRoutes))],
provideServerRendering(withRoutes(serverRoutes))
]
}; };
export const config = mergeApplicationConfig(appConfig, serverConfig); export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -1,12 +1,29 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import {
provideRouter,
TitleStrategy,
withComponentInputBinding,
withInMemoryScrolling,
} from '@angular/router';
import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideClientHydration(withEventReplay()) provideRouter(
] routes,
withComponentInputBinding(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
provideClientHydration(withEventReplay()),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
],
}; };

View File

@@ -1,342 +1,89 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * --> <app-dot-background aria-hidden="true"></app-dot-background>
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * --> <div class="site" [class.nav-collapsed]="!navOpen()">
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * --> <header class="site-header">
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <div class="content-container site-header-inner glass-surface">
<!-- * * * * * * * * * Delete the template below * * * * * * * * * --> <a class="site-identity" [routerLink]="navigation.link('home')">
<!-- * * * * * * * to get started with your project! * * * * * * * --> {{ siteConfig.personName }}
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> </a>
<div class="site-toolbar cluster">
<style> <button
:host { type="button"
--bright-blue: oklch(51.01% 0.274 263.83); class="nav-toggle"
--electric-violet: oklch(53.18% 0.28 296.97); [attr.aria-expanded]="navOpen()"
--french-violet: oklch(47.66% 0.246 305.88); aria-controls="primary-nav"
--vivid-pink: oklch(69.02% 0.277 332.77); [attr.aria-label]="menuLabel()"
--hot-red: oklch(61.42% 0.238 15.34); (click)="toggleNav()"
--orange-red: oklch(63.32% 0.24 31.68); >
<span aria-hidden="true"></span>
--gray-900: oklch(19.37% 0.006 300.98); </button>
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div> </div>
<div class="social-links"> <nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
<ul class="primary-nav" id="primary-nav">
@for (item of navigation.primaryNav(); track item.routeId) {
<li>
<a
[routerLink]="item.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
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>
</nav>
<div class="site-actions cluster">
<a <a
href="https://github.com/angular/angular" class="language-switch"
aria-label="Github" [routerLink]="navigation.alternateLocaleLink()"
target="_blank" [attr.hreflang]="otherHtmlLang()"
rel="noopener" [attr.aria-label]="shell().languageSwitch"
> >
<svg {{ shell().otherLocaleName }}
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a> </a>
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
</div> </div>
</div> </div>
</div> </header>
</main> <main id="main-content" class="site-main" tabindex="-1">
<router-outlet />
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> </main>
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * --> <footer class="site-footer">
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * --> <div class="content-container site-footer-inner cluster">
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * --> <p>{{ siteConfig.personName }}</p>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <a [href]="'mailto:' + siteConfig.contactEmail">{{ siteConfig.contactEmail }}</a>
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * --> <nav [attr.aria-label]="shell().footerNavLabel">
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <ul class="cluster footer-nav">
@for (item of navigation.footerNav(); track item.routeId) {
<li>
<router-outlet /> <a
[routerLink]="item.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>{{ item.label }}</a
>
</li>
}
</ul>
</nav>
</div>
</footer>
</div>
<app-terminal-dock></app-terminal-dock>

View File

@@ -0,0 +1,22 @@
import { RenderMode } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
describe('server routes', () => {
it('returns HTTP 404 from the catch-all and leaves prerendered routes without a status', () => {
const catchAll = serverRoutes.find((route) => route.path === '**');
expect(catchAll?.renderMode).toBe(RenderMode.Server);
expect(catchAll && 'status' in catchAll ? catchAll.status : undefined).toBe(404);
const prerendered = serverRoutes.filter((route) => route.path !== '**');
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);
expect('status' in route, `${route.path} must not set status`).toBe(false);
}
});
});

View File

@@ -1,8 +1,14 @@
import { RenderMode, ServerRoute } from '@angular/ssr'; import { RenderMode, type ServerRoute } from '@angular/ssr';
import { prerenderableServerPaths } from './core/routing/route-paths';
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
...prerenderableServerPaths().map((path): ServerRoute => ({
path,
renderMode: RenderMode.Prerender,
})),
{ {
path: '**', path: '**',
renderMode: RenderMode.Prerender renderMode: RenderMode.Server,
} status: 404,
},
]; ];

View File

@@ -0,0 +1,90 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, type Routes } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { LocaleService } from './core/i18n/locale.service';
import { type AppRouteData } from './core/routing/app-route-data';
function flattenRoutes(
tree: Routes,
prefix = '',
): Array<{ path: string; data?: AppRouteData; title?: string }> {
const entries: Array<{ path: string; data?: AppRouteData; title?: string }> = [];
for (const route of tree) {
const segment = route.path ?? '';
const path = [prefix, segment].filter((part) => part.length > 0).join('/');
entries.push({
path,
data: route.data as AppRouteData | undefined,
title: typeof route.title === 'string' ? route.title : undefined,
});
if (route.children) {
entries.push(...flattenRoutes(route.children, path));
}
}
return entries;
}
describe('app routes', () => {
it('places the English subtree first and attaches locale data', () => {
expect(routes[0]?.path).toBe('en');
const flattened = flattenRoutes(routes);
const englishProjects = flattened.find((entry) => entry.path === 'en/projects');
const germanProjects = flattened.find((entry) => entry.path === 'projekte');
const englishWildcard = flattened.find((entry) => entry.path === 'en/**');
const germanWildcard = flattened.find((entry) => entry.path === '**');
expect(englishProjects?.data).toEqual({ routeId: 'projects', locale: 'en' });
expect(germanProjects?.data).toEqual({ routeId: 'projects', locale: 'de' });
expect(englishWildcard?.data).toEqual({ routeId: 'notFound', locale: 'en' });
expect(germanWildcard?.data).toEqual({ routeId: 'notFound', locale: 'de' });
});
it('navigates to the English projects page', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
const locale = TestBed.inject(LocaleService);
await harness.navigateByUrl('/en/projects');
expect(locale.locale()).toBe('en');
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toContain(
SITE_CONTENT_DATA.en.pages.projects.hero.headline,
);
expect(harness.routeNativeElement?.textContent).toContain(
SITE_CONTENT_DATA.en.cases.innofocus.client,
);
});
it('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);
for (const entry of flattened) {
if (!entry.data) {
continue;
}
const expected = SITE_CONTENT_DATA[entry.data.locale].pages[entry.data.routeId].title;
expect(entry.title, entry.path).toBe(expected);
}
});
});

View File

@@ -1,3 +1,70 @@
import { Routes } from '@angular/router'; import { type Type } from '@angular/core';
import { type Routes } from '@angular/router';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { localeResolver } from './core/i18n/locale.resolver';
import { type AppLocale } from './core/i18n/locale';
import { type AppRouteData } from './core/routing/app-route-data';
import { ROUTE_IDS, type RouteId } from './core/routing/route-ids';
import { LOCALE_PREFIX, ROUTE_SEGMENTS } from './core/routing/route-paths';
export const routes: Routes = []; 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),
contact: () => import('./features/contact/contact').then((module) => module.ContactPage),
imprint: () => import('./features/imprint/imprint').then((module) => module.ImprintPage),
privacy: () => import('./features/privacy/privacy').then((module) => module.PrivacyPage),
notFound: () => import('./features/not-found/not-found').then((module) => module.NotFoundPage),
};
function routeData(routeId: RouteId, locale: AppLocale): AppRouteData {
return { routeId, locale };
}
function localeRoutes(locale: AppLocale): Routes {
const segments = ROUTE_SEGMENTS[locale];
const pages = SITE_CONTENT_DATA[locale].pages;
return ROUTE_IDS.filter((routeId) => routeId !== 'notFound')
.map((routeId) => ({
path: segments[routeId],
loadComponent: PAGE_LOADERS[routeId],
data: routeData(routeId, locale),
title: pages[routeId].title,
resolve: { locale: localeResolver },
}))
.concat([
{
path: '**',
loadComponent: PAGE_LOADERS.notFound,
data: routeData('notFound', locale),
title: pages.notFound.title,
resolve: { locale: localeResolver },
},
]);
}
export const routes: Routes = [
{
path: LOCALE_PREFIX.en,
children: localeRoutes('en'),
},
...localeRoutes('de'),
];

View File

@@ -0,0 +1,235 @@
@use 'breakpoints' as bp;
:host {
display: block;
min-height: 100vh;
color: var(--color-text);
font-family: var(--font-sans);
}
.site {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-header {
position: relative;
top: 0;
z-index: 30;
padding-block: var(--space-3);
}
.site-header-inner {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3);
align-items: start;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a {
color: var(--color-text);
text-decoration: none;
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a,
.nav-toggle {
min-height: 2.75rem;
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a {
display: inline-flex;
align-items: center;
}
.site-identity {
font-weight: 600;
}
.site-toolbar {
justify-self: end;
align-items: center;
}
.nav-toggle {
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
display: block;
width: 1rem;
height: 2px;
margin-inline: auto;
background: currentColor;
}
.nav-toggle span::before,
.nav-toggle span::after {
content: '';
position: relative;
}
.nav-toggle span::before {
top: -0.35rem;
}
.nav-toggle span::after {
top: 0.25rem;
}
.site-nav,
.site-actions {
grid-column: 1 / -1;
}
.primary-nav,
.primary-nav ul,
.footer-nav {
list-style: none;
margin: 0;
padding: 0;
}
.primary-nav {
display: flex;
flex-direction: column;
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);
}
.nav-collapsed .site-nav,
.nav-collapsed .site-actions {
display: none;
}
.site-main {
flex: 1;
}
.site-main:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.site-footer {
padding-block: var(--space-6);
color: var(--color-text-muted);
}
.site-footer-inner {
justify-content: space-between;
}
.site-footer p {
margin: 0;
}
@include bp.respond-to(md) {
.nav-toggle {
display: none;
}
.site-header {
padding-block: var(--space-2);
}
.site-header-inner {
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-2);
padding-block: var(--space-2);
}
.site-nav,
.nav-collapsed .site-nav {
display: flex;
grid-column: 1 / -1;
order: 5;
}
.site-actions,
.nav-collapsed .site-actions {
display: flex;
grid-column: auto;
order: 3;
}
.site-toolbar {
order: 2;
}
.primary-nav {
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-3);
}
.primary-nav > li {
position: relative;
z-index: 31;
}
.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: 32;
padding: var(--space-3) var(--space-4);
gap: var(--space-1);
border-radius: var(--radius-md);
background: var(--color-surface-menu);
border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-raised);
}
.primary-nav > li:focus-within > .submenu {
display: flex;
flex-direction: column;
}
@media (hover: hover) and (pointer: fine) {
.primary-nav > li:hover > .submenu {
display: flex;
flex-direction: column;
}
}
}

View File

@@ -1,23 +1,173 @@
import { TestBed } from '@angular/core/testing'; import { ApplicationRef, PLATFORM_ID } from '@angular/core';
import { App } from './app'; import { TestBed, type ComponentFixture } from '@angular/core/testing';
import { provideRouter, Router, TitleStrategy } from '@angular/router';
import { App, WIDE_NAV_QUERY } from './app';
import { routes } from './app.routes';
import { SITE_CONTENT } from './core/content/content.token';
import { SITE_CONTENT_DATA } from './core/content/site-content';
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
function mockViewport(wide: boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: wide && query === WIDE_NAV_QUERY,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
async function configureApp(
extraProviders: { provide: unknown; useValue: unknown }[] = [],
): Promise<void> {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
...extraProviders,
],
}).compileComponents();
}
async function flush(fixture: ComponentFixture<App>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
await fixture.whenStable();
}
function toggle(root: HTMLElement): HTMLButtonElement {
return root.querySelector('.nav-toggle') as HTMLButtonElement;
}
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
imports: [App], mockViewport(true);
}).compileComponents();
await configureApp();
}); });
it('should create the app', () => { afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
});
it('should create the shell', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance; await fixture.whenStable();
expect(app).toBeTruthy(); expect(fixture.componentInstance).toBeTruthy();
}); });
it('should render title', async () => { it('should render an accessible application shell', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Portfolio');
const focusable = compiled.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const firstFocusable = focusable.item(0);
expect(firstFocusable).toBeTruthy();
expect(firstFocusable.getAttribute('href')).toBe('#main-content');
expect(firstFocusable.classList.contains('skip-link')).toBe(true);
expect(compiled.querySelector('main#main-content')).toBeTruthy();
expect(compiled.querySelector('nav[aria-label]')).toBeTruthy();
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
expect(compiled.querySelector('footer')).toBeTruthy();
});
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 dock = compiled.querySelector('app-terminal-dock');
const trigger = compiled.querySelector('.terminal-dock-trigger');
expect(site).toBeTruthy();
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 panel = compiled.querySelector('.terminal-dock-panel');
expect(panel).toBeTruthy();
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 () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
const shell = fixture.componentInstance as unknown as { navOpen: () => boolean };
expect(shell.navOpen()).toBe(true);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('aria-controls')).toBe('primary-nav');
expect(button.getAttribute('aria-label')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
true,
);
});
it('does not collapse the nav on the server even when the viewport helper would be narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
const fixture = TestBed.createComponent(App);
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('true');
expect(fixture.nativeElement.querySelector('.site')?.classList.contains('nav-collapsed')).toBe(
false,
);
});
it('closes the nav on navigation when the viewport is narrow', async () => {
TestBed.resetTestingModule();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
mockViewport(false);
await configureApp();
const fixture = TestBed.createComponent(App);
await flush(fixture);
const button = toggle(fixture.nativeElement);
expect(button.getAttribute('aria-expanded')).toBe('false');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-expanded')).toBe('true');
await TestBed.inject(Router).navigateByUrl('/projekte');
await flush(fixture);
expect(toggle(fixture.nativeElement).getAttribute('aria-expanded')).toBe('false');
}); });
}); });

View File

@@ -1,12 +1,109 @@
import { Component, signal } from '@angular/core'; import { DOCUMENT, ViewportScroller } from '@angular/common';
import { RouterOutlet } from '@angular/router'; import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import { DotBackground } from './components/dot-background/dot-background';
import { SHELL_COPY } from './core/content/shell-copy';
import { SITE_CONFIG } from './core/content/site-config';
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service';
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
import { TerminalDock } from './shared/terminal/terminal-dock';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss',
}) })
export class App { export class App {
protected readonly title = signal('Portfolio'); protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT);
private readonly viewportScroller = inject(ViewportScroller);
private readonly isBrowser = isBrowserPlatform();
private readonly wideNav = viewportMatches(WIDE_NAV_QUERY);
protected readonly siteConfig = SITE_CONFIG;
protected readonly navOpen = signal(true);
protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]);
protected readonly otherLocale = computed(() => otherLocale(this.localeService.locale()));
protected readonly otherHtmlLang = computed(() => LOCALE_HTML_LANG[this.otherLocale()]);
protected readonly menuLabel = computed(() =>
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
);
constructor() {
afterNextRender(
() => {
this.collapseNavIfNarrow();
this.bindHeaderScrollOffset();
},
{ injector: this.injector },
);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.collapseNavIfNarrow());
}
protected toggleNav(): void {
this.navOpen.update((open) => !open);
}
private collapseNavIfNarrow(): void {
if (!this.isBrowser || this.wideNav) {
return;
}
this.navOpen.set(false);
}
private bindHeaderScrollOffset(): void {
if (!this.isBrowser) {
return;
}
this.viewportScroller.setOffset(() => [0, this.headerOffsetPx()]);
}
private headerOffsetPx(): number {
const view = this.document.defaultView;
if (!view) {
return 0;
}
const styles = view.getComputedStyle(this.document.documentElement);
const raw = styles.getPropertyValue('--header-offset').trim();
const numeric = Number.parseFloat(raw);
if (!Number.isFinite(numeric)) {
return 0;
}
if (raw.endsWith('rem')) {
const rootSize = Number.parseFloat(styles.fontSize);
return numeric * (Number.isFinite(rootSize) ? rootSize : 16);
}
return numeric;
}
} }

View File

@@ -0,0 +1 @@
<canvas #canvas></canvas>

View File

@@ -0,0 +1,11 @@
canvas {
position: fixed;
inset: 0;
z-index: -1;
background: var(--color-surface);
width: 100vw;
height: 100vh;
width: 100lvw;
height: 100lvh;
}

View File

@@ -0,0 +1,281 @@
import { ApplicationRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DotBackground } from './dot-background';
function mockContext(): CanvasRenderingContext2D {
const gradient = { addColorStop: vi.fn() };
return {
clearRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
} as unknown as CanvasRenderingContext2D;
}
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string): MediaQueryList =>
({
matches: matchesQuery(query),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as MediaQueryList,
});
}
describe('DotBackground', () => {
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(window, 'matchMedia');
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
});
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
fixture.detectChanges();
return fixture;
}
it('should create', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const fixture = await createFixture();
expect(fixture.componentInstance).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const fixture = await createFixture();
expect(() => fixture.destroy()).not.toThrow();
});
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
expect(() => fixture.destroy()).not.toThrow();
});
it('cancels the scheduled frame and removes every listener on destroy', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
const windowAdd = vi.spyOn(window, 'addEventListener');
const windowRemove = vi.spyOn(window, 'removeEventListener');
const documentAdd = vi.spyOn(document, 'addEventListener');
const documentRemove = vi.spyOn(document, 'removeEventListener');
const fixture = await createFixture();
expect(raf).toHaveBeenCalled();
const windowAdds = windowAdd.mock.calls.filter((call) =>
['resize', 'mousemove', 'click'].includes(String(call[0])),
);
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
expect(windowAdds.length).toBeGreaterThan(0);
expect(documentAdds.length).toBeGreaterThan(0);
fixture.destroy();
expect(cancel).toHaveBeenCalled();
for (const [type, handler] of windowAdds) {
expect(windowRemove).toHaveBeenCalledWith(type, handler);
}
for (const [type, handler] of documentAdds) {
expect(documentRemove).toHaveBeenCalledWith(type, handler);
}
});
it('draws a single static frame under reduced motion and skips pointer listeners', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
const windowAdd = vi.spyOn(window, 'addEventListener');
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
expect(context.clearRect).toHaveBeenCalled();
expect(scheduled).toEqual([]);
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
fixture.destroy();
});
it('registers no pointer listeners for a coarse pointer', async () => {
mockMatchMedia((query) => query.includes('pointer: coarse'));
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
const windowAdd = vi.spyOn(window, 'addEventListener');
await createFixture();
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
});
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
await createFixture();
expect(raf).toHaveBeenCalled();
raf.mockClear();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => true,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(cancel).toHaveBeenCalled();
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
document.dispatchEvent(new Event('visibilitychange'));
expect(raf).toHaveBeenCalled();
});
it('starts with a bounded initial dot count on a large desktop viewport', async () => {
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560);
const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
try {
const fixture = await createFixture();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
const arcCalls = vi.mocked(context.arc).mock.calls.length;
expect(arcCalls).toBeGreaterThanOrEqual(6);
expect(arcCalls).toBeLessThanOrEqual(24);
fixture.destroy();
} finally {
widthStub.mockRestore();
heightStub.mockRestore();
}
});
it('redraws the static frame after resize under reduced motion without starting a loop', async () => {
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
const context = mockContext();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
const scheduled: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
scheduled.push(callback);
return scheduled.length;
});
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [DotBackground],
}).compileComponents();
const fixture = TestBed.createComponent(DotBackground);
fixture.detectChanges();
await fixture.whenStable();
TestBed.inject(ApplicationRef).tick();
const pending = scheduled.splice(0);
for (const callback of pending) {
callback(0);
}
vi.mocked(context.clearRect).mockClear();
vi.mocked(context.arc).mockClear();
window.dispatchEvent(new Event('resize'));
const resizeFrames = scheduled.splice(0);
for (const callback of resizeFrames) {
callback(0);
}
expect(context.clearRect).toHaveBeenCalled();
expect(context.arc).toHaveBeenCalled();
expect(scheduled).toEqual([]);
fixture.destroy();
});
});

View File

@@ -0,0 +1,368 @@
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
inject,
NgZone,
ViewChild,
} from '@angular/core';
import {
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
} from '../../core/platform/browser';
import { Dot } from '../../models/dot';
@Component({
selector: 'app-dot-background',
imports: [],
templateUrl: './dot-background.html',
styleUrl: './dot-background.scss',
host: {
'aria-hidden': 'true',
},
})
export class DotBackground {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly document = inject(DOCUMENT);
private readonly ngZone = inject(NgZone);
private readonly destroyRef = inject(DestroyRef);
private readonly isBrowser = isBrowserPlatform();
private readonly coarsePointer = prefersCoarsePointer();
private readonly reducedMotion = prefersReducedMotion();
private ctx: CanvasRenderingContext2D | undefined;
private dots: Dot[] = [];
private mouse = { x: -1000, y: -1000 };
private animationId = 0;
private resizeFrameId = 0;
private loopActive = false;
private tornDown = false;
private readonly teardowns: Array<() => void> = [];
private readonly INITIAL_DOT_COUNT_MIN = 6;
private readonly INITIAL_DOT_COUNT_MAX = 24;
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
private readonly MAX_DOT_COUNT = 100;
private readonly MAX_DOT_COUNT_MOBILE = 40;
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
private readonly MOUSE_RADIUS = 150;
private ballSpawnId = 0;
private ballSpawnNextColor = 0;
constructor() {
this.destroyRef.onDestroy(() => this.teardown());
afterNextRender(() => {
this.init();
});
}
private view(): Window | null {
return this.document.defaultView;
}
private init(): void {
if (this.tornDown || !this.isBrowser) {
return;
}
const view = this.view();
if (!view) {
return;
}
const canvas = this.canvasRef?.nativeElement;
if (!canvas) {
return;
}
let ctx: CanvasRenderingContext2D | null = null;
try {
ctx = canvas.getContext('2d');
} catch {
return;
}
if (!ctx) {
return;
}
this.ctx = ctx;
this.resize();
this.initDots();
this.listen(view, 'resize', this.onResize);
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
if (!this.reducedMotion && !this.coarsePointer) {
this.listen(view, 'mousemove', this.onMouseMove);
this.listen(view, 'click', this.onMouseClick);
}
if (this.reducedMotion) {
this.drawFrame();
return;
}
this.ngZone.runOutsideAngular(() => this.startLoop());
}
private listen(target: EventTarget, type: string, handler: EventListener): void {
target.addEventListener(type, handler);
this.teardowns.push(() => target.removeEventListener(type, handler));
}
private requestFrame(callback: FrameRequestCallback): number {
const view = this.view();
return view ? view.requestAnimationFrame(callback) : 0;
}
private cancelFrame(id: number): void {
const view = this.view();
if (!view || id === 0) {
return;
}
view.cancelAnimationFrame(id);
}
private startLoop(): void {
if (this.loopActive || this.reducedMotion || this.tornDown) {
return;
}
this.loopActive = true;
this.scheduleAnimate();
}
private stopLoop(): void {
this.loopActive = false;
this.cancelFrame(this.animationId);
this.animationId = 0;
}
private scheduleAnimate(): void {
this.animationId = this.requestFrame(this.animate);
}
private teardown(): void {
if (this.tornDown) {
return;
}
this.tornDown = true;
this.stopLoop();
this.cancelFrame(this.resizeFrameId);
this.resizeFrameId = 0;
for (const dispose of this.teardowns) {
dispose();
}
this.teardowns.length = 0;
}
private onResize = (): void => {
if (this.resizeFrameId !== 0) {
return;
}
this.resizeFrameId = this.requestFrame(() => {
this.resizeFrameId = 0;
this.resize();
});
};
private onVisibilityChange = (): void => {
if (this.document.hidden) {
this.stopLoop();
return;
}
if (!this.reducedMotion) {
this.ngZone.runOutsideAngular(() => this.startLoop());
}
};
private resize = (): void => {
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
const width = view.innerWidth;
const height = view.innerHeight;
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
canvas.width = width;
canvas.height = height;
for (const dot of this.dots) {
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
}
if (this.reducedMotion && this.ctx) {
this.drawFrame();
}
}
};
private onMouseMove = (event: Event): void => {
if (!(event instanceof MouseEvent)) {
return;
}
const view = this.view();
const canvas = this.canvasRef?.nativeElement;
if (!view || !canvas) {
return;
}
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
};
private onMouseClick = (): void => {
const dot = this.spawnDot();
dot.x = this.mouse.x;
dot.y = this.mouse.y;
};
private targetDotCount(width: number, height: number): number {
const maxInitial = this.coarsePointer
? this.INITIAL_DOT_COUNT_MAX_COARSE
: this.INITIAL_DOT_COUNT_MAX;
const area = Math.max(0, width) * Math.max(0, height);
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
}
private spawnDot(): Dot {
const dotId = this.ballSpawnId++;
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
let dot: Dot;
if (dotId < maxCount) {
dot = {
x: 0,
y: 0,
vx: 0,
vy: 0,
radius: 1,
color: '#000000',
};
this.dots.push(dot);
} else {
dot = this.dots[dotId % this.dots.length];
}
this.populateDot(dot);
return dot;
}
private populateDot(dot: Dot): void {
const { width, height } = this.canvasRef.nativeElement;
dot.x = Math.random() * width;
dot.y = Math.random() * height;
dot.vx = (Math.random() - 0.5) * 0.5;
dot.vy = (Math.random() - 0.5) * 0.5;
dot.radius = Math.random() * 60 + 40;
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
}
private initDots(): void {
const { width, height } = this.canvasRef.nativeElement;
const count = this.targetDotCount(width, height);
for (let i = 0; i < count; i++) {
this.spawnDot();
}
}
private animate = (): void => {
this.animationId = 0;
this.drawFrame();
if (this.loopActive && !this.tornDown) {
this.scheduleAnimate();
}
};
private drawFrame(): void {
const canvas = this.canvasRef?.nativeElement;
const ctx = this.ctx;
if (!canvas || !ctx) {
return;
}
const { width, height } = canvas;
ctx.clearRect(0, 0, width, height);
for (const dot of this.dots) {
const dx = dot.x - this.mouse.x;
const dy = dot.y - this.mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist != 0 && dist < this.MOUSE_RADIUS) {
const force = (this.MOUSE_RADIUS - dist) / this.MOUSE_RADIUS;
dot.vx += (dx / dist) * force * 0.5;
dot.vy += (dy / dist) * force * 0.5;
}
dot.x += dot.vx;
dot.y += dot.vy;
const speed = Math.sqrt(dot.vx * dot.vx + dot.vy * dot.vy);
if (speed > 0.3) {
dot.vx *= 0.98;
dot.vy *= 0.98;
} else if (speed <= 0.3) {
dot.vx *= 1.02;
dot.vy *= 1.02;
} else if (speed === 0) {
dot.vx = Math.random() * 0.2 - 0.1;
dot.vy = Math.random() * 0.2 - 0.1;
}
if (dot.x < dot.radius || dot.x > width - dot.radius) {
dot.vx *= -1;
}
if (dot.y < dot.radius || dot.y > height - dot.radius) {
dot.vy *= -1;
}
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
const gradient = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, dot.radius);
gradient.addColorStop(0, dot.color + '40');
gradient.addColorStop(1, 'transparent');
ctx.beginPath();
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
}
}

View File

@@ -0,0 +1,19 @@
<div class="card glass-surface">
<h3>{{ title }}</h3>
<div class="icons">
@for (skill of skills; track skill.icon) {
<a
[href]="skill.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="skill.name"
>
<img
[src]="'/' + category + '/' + skill.icon + '.svg'"
[alt]="skill.name"
[title]="skill.name"
/>
</a>
}
</div>
</div>

View File

@@ -0,0 +1,56 @@
.card {
border-radius: var(--radius-lg);
padding: var(--space-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);
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
}
.icons {
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
align-items: center;
flex: 1;
a {
position: relative;
display: flex;
img {
width: 40px;
height: 40px;
object-fit: contain;
filter: grayscale(100%) brightness(0.8);
transition:
filter var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
}
}
}
@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);
}
}

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SkillCard } from './skill-card';
describe('SkillCard', () => {
let component: SkillCard;
let fixture: ComponentFixture<SkillCard>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillCard],
}).compileComponents();
fixture = TestBed.createComponent(SkillCard);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,14 @@
import { Component, Input } from '@angular/core';
import { Skill } from '../../../../models/skill';
@Component({
selector: 'app-skill-card',
imports: [],
templateUrl: './skill-card.html',
styleUrl: './skill-card.scss',
})
export class SkillCard {
@Input({ required: true }) title!: string;
@Input({ required: true }) category!: string;
@Input({ required: true }) skills!: Skill[];
}

View File

@@ -0,0 +1,10 @@
<div class="grid">
@for (cat of categories(); track cat.category) {
<app-skill-card
[title]="cat.title"
[category]="cat.category"
[skills]="cat.skills"
[style.grid-area]="cat.gridArea"
/>
}
</div>

View File

@@ -0,0 +1,6 @@
.grid {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: var(--space-3);
}

View File

@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SkillsGrid } from './skills-grid';
describe('SkillsGrid', () => {
let component: SkillsGrid;
let fixture: ComponentFixture<SkillsGrid>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillsGrid],
}).compileComponents();
fixture = TestBed.createComponent(SkillsGrid);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,129 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { SkillCategory } from '../../../../models/skill-category';
import { SkillCard } from '../skill-card/skill-card';
const BASE_CATEGORIES: readonly SkillCategory[] = [
{
title: 'Programming',
category: 'programming',
gridArea: 'prog',
skills: [
{ name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' },
{
name: 'JavaScript',
icon: 'javascript',
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript',
},
{ name: 'Angular', icon: 'angular', url: 'https://angular.dev/' },
{ name: 'Java', icon: 'java', url: 'https://www.java.com/' },
{ name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' },
{ name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' },
{ name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' },
{ name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' },
{ name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' },
],
},
{
title: 'Databases',
category: 'db',
gridArea: 'db',
skills: [
{
name: 'SQL Server',
icon: 'microsoftsqlserver',
url: 'https://www.microsoft.com/en-us/sql-server',
},
{ name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' },
{ name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' },
{ name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' },
],
},
{
title: 'DevOps',
category: 'devops',
gridArea: 'devops',
skills: [
{ name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' },
{ name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' },
{ name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' },
{
name: 'Azure DevOps',
icon: 'devops',
url: 'https://azure.microsoft.com/en-us/products/devops',
},
{ name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' },
{ name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' },
{ name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' },
],
},
{
title: 'Operating Systems',
category: 'os',
gridArea: 'os',
skills: [
{ name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' },
{ name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' },
{ name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' },
{ name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' },
],
},
{
title: 'Infrastructure as Code',
category: 'iac',
gridArea: 'iac',
skills: [
{ name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' },
{ name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' },
],
},
{
title: 'Hypervisors',
category: 'hyperviser',
gridArea: 'hyper',
skills: [
{ name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' },
{
name: 'Hyper-V',
icon: 'hyperv',
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
},
],
},
{
title: 'Tools',
category: 'tools',
gridArea: 'tools',
skills: [
{ name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' },
{ name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' },
{
name: 'Microsoft Office',
icon: 'office',
url: 'https://www.microsoft.com/microsoft-365',
},
],
},
];
export const SKILL_GRID_ITEM_NAMES: readonly string[] = BASE_CATEGORIES.flatMap((category) =>
category.skills.map((skill) => skill.name),
);
@Component({
selector: 'app-skills-grid',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkillCard],
templateUrl: './skills-grid.html',
styleUrl: './skills-grid.scss',
})
export class SkillsGrid {
readonly categoryTitles = input<Readonly<Record<string, string>>>({});
protected readonly categories = computed(() => {
const titles = this.categoryTitles();
return BASE_CATEGORIES.map((category) => ({
...category,
title: titles[category.category] ?? category.title,
}));
});
}

View File

@@ -0,0 +1,10 @@
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
export const COMMAND_IDS: readonly CommandId[] = [
'help',
'history',
'clear',
'brew',
'rev',
'navigate',
];

View File

@@ -0,0 +1,137 @@
import { APP_LOCALES } from '../i18n/locale';
import {
CASE_STUDY_IDS,
type CaseStudyCopy,
type MetricCopy,
type OfferingCopy,
type SiteContent,
} from './content.contracts';
import { SITE_CONTENT_DATA } from './site-content';
const DELIVERED_OFFER_WORDING =
/für kunden umgesetzt|im kundeneinsatz|in production for clients|delivered for clients|langjährige erfahrung mit rag|years of rag/i;
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.pitch.metrics, ...CASE_STUDY_IDS.flatMap((id) => site.cases[id].metrics)];
}
function allOfferings(site: SiteContent): readonly OfferingCopy[] {
return Object.values(site.services).flatMap((page) => page.offerings);
}
function caseText(caseStudy: CaseStudyCopy): string {
return [
caseStudy.summary,
caseStudy.responsibility,
caseStudy.situation,
...caseStudy.approach,
...caseStudy.outcome,
...caseStudy.metrics.map((metric) => `${metric.value} ${metric.label} ${metric.note ?? ''}`),
].join('\n');
}
describe('content claims and attribution', () => {
it('keeps the four public cases and attributes verified facts to the right ones', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
expect(Object.keys(site.cases)).toEqual([...CASE_STUDY_IDS]);
expect(CASE_STUDY_IDS).toEqual(['innofocus', 'roesterei', 'myspa', 'hdi']);
const matchesRuntime = (metric: MetricCopy) =>
/8/.test(metric.value) && /90/.test(metric.value);
const runtimeMetrics = allMetrics(site).filter(matchesRuntime);
expect(runtimeMetrics.every((metric) => metric.caseId === 'innofocus')).toBe(true);
expect(site.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);
}
const leadershipMetrics = allMetrics(site).filter(
(metric) =>
metric.caseId === 'myspa' &&
/4/.test(`${metric.value} ${metric.label}`) &&
/2/.test(`${metric.value} ${metric.label}`),
);
expect(leadershipMetrics.length).toBeGreaterThan(0);
const hdiText = caseText(site.cases.hdi);
expect(hdiText).toMatch(/Azure AKS/);
expect(hdiText).toMatch(/Azure App Services/);
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'hdi')) {
expect(caseText(site.cases[caseId])).not.toMatch(/Azure App Services/);
}
}
});
it('keeps AI delivery claims limited to the two Rösterei tools', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const aiOfferings = site.services.servicesAi.offerings;
const delivered = aiOfferings.filter((offering) => offering.status === 'delivered');
expect(delivered).toHaveLength(2);
expect(delivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
const otherOfferings = allOfferings(site).filter((offering) => !delivered.includes(offering));
expect(otherOfferings.every((offering) => offering.status === 'offer')).toBe(true);
expect(
otherOfferings.some((offering) =>
DELIVERED_OFFER_WORDING.test(`${offering.title} ${offering.body}`),
),
).toBe(false);
}
});
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);
}
});
});

View File

@@ -0,0 +1,118 @@
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS } from '../routing/route-ids';
import { type SiteContent } from './content.contracts';
import { SITE_CONTENT_DATA } from './site-content';
const TITLE_SUFFIX = ' | Antonio Ledebuhr';
const FORBIDDEN =
/lorem|todo|tbd|platzhaltertext|placeholder text|wird derzeit aufgebaut|being built|coming soon|xxx/i;
function walkStrings(
value: unknown,
visit: (text: string, path: string) => void,
path = '',
skipKeys: ReadonlySet<string> = new Set(),
): void {
if (typeof value === 'string') {
visit(value, path);
return;
}
if (Array.isArray(value)) {
value.forEach((item, index) => walkStrings(item, visit, `${path}[${index}]`, skipKeys));
return;
}
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
if (skipKeys.has(key)) {
continue;
}
walkStrings(child, visit, path ? `${path}.${key}` : key, skipKeys);
}
}
}
describe('content completeness', () => {
it('covers every route in both locales with real page copy', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
expect(site.pages.home).toBe(site.home);
expect(site.pages.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);
expect(site.pages.imprint).toBe(site.legal.imprint);
expect(site.pages.privacy).toBe(site.legal.privacy);
expect(site.pages.servicesAi).toBe(site.services.servicesAi);
for (const routeId of ROUTE_IDS) {
const page = site.pages[routeId];
expect(page, `${locale}.${routeId}`).toBeTruthy();
expect(page.title.length).toBeGreaterThan(0);
expect(page.title.endsWith(TITLE_SUFFIX)).toBe(true);
expect(page.description.length).toBeGreaterThanOrEqual(60);
expect(page.hero.headline.length).toBeGreaterThan(0);
expect(page.hero.headline.includes(TITLE_SUFFIX)).toBe(false);
if (routeId !== 'notFound') {
const filledSections = page.sections.filter((section) =>
(section.body ?? []).some((paragraph) => paragraph.trim().length > 0),
);
expect(filledSections.length, `${locale}.${routeId} sections`).toBeGreaterThanOrEqual(2);
}
}
}
});
it('keeps the content tree free of scaffolding, except legal review fields', () => {
const skipped = new Set(['reviewNotice', 'reviewTodos']);
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const hits: string[] = [];
walkStrings(
site,
(text, path) => {
if (FORBIDDEN.test(text)) {
hits.push(`${path}: ${text}`);
}
},
locale,
skipped,
);
expect(hits).toEqual([]);
for (const legalId of ['imprint', 'privacy'] as const) {
const legal = site.legal[legalId];
expect(legal.reviewNotice.trim().length).toBeGreaterThan(20);
expect(legal.reviewTodos.length).toBeGreaterThan(0);
expect(legal.reviewTodos.every((item) => item.trim().length > 0)).toBe(true);
}
}
});
it('does not treat legal review fields as an implicit skip of the rest of the legal pages', () => {
for (const locale of APP_LOCALES) {
const site: SiteContent = SITE_CONTENT_DATA[locale];
for (const legalId of ['imprint', 'privacy'] as const) {
const legal = site.legal[legalId];
const remainderHits: string[] = [];
walkStrings(
{ hero: legal.hero, sections: legal.sections, legalSections: legal.legalSections },
(text, path) => {
if (FORBIDDEN.test(text)) {
remainderHits.push(`${path}: ${text}`);
}
},
`${locale}.${legalId}`,
);
expect(remainderHits).toEqual([]);
}
}
});
});

View File

@@ -0,0 +1,72 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { SITE_CONTENT } from './content.token';
import { SITE_CONTENT_DATA } from './site-content';
import { SHELL_COPY } from './shell-copy';
import { FOOTER_NAV, PRIMARY_NAV } from '../navigation/navigation';
const EXCLUDED = new RegExp(
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading'].join('|')})\\b`,
'i',
);
function walkStrings(value: unknown, visit: (text: string) => void): void {
if (typeof value === 'string') {
visit(value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => walkStrings(item, visit));
return;
}
if (value && typeof value === 'object') {
Object.values(value).forEach((child) => walkStrings(child, visit));
}
}
function collectHits(value: unknown): string[] {
const hits: string[] = [];
walkStrings(value, (text) => {
if (EXCLUDED.test(text)) {
hits.push(text);
}
});
return hits;
}
describe('content exclusions', () => {
it('keeps excluded former stations out of copy data and navigation labels', () => {
expect(collectHits(SITE_CONTENT_DATA)).toEqual([]);
expect(collectHits(SHELL_COPY)).toEqual([]);
expect(collectHits(PRIMARY_NAV)).toEqual([]);
expect(collectHits(FOOTER_NAV)).toEqual([]);
});
it('keeps excluded former stations out of rendered home, projects and about pages', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
const harness = await RouterTestingHarness.create();
const paths = [
'/',
'/en',
'/pitch',
'/en/pitch',
'/projekte',
'/en/projects',
'/ueber-mich',
'/en/about',
];
for (const path of paths) {
await harness.navigateByUrl(path);
const text = harness.routeNativeElement?.textContent ?? '';
expect(EXCLUDED.test(text), path).toBe(false);
}
});
});

View File

@@ -0,0 +1,87 @@
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS } from '../routing/route-ids';
import { CASE_STUDY_IDS } from './content.contracts';
import { SITE_CONTENT_DATA } from './site-content';
const STRUCTURAL_KEYS = new Set([
'id',
'status',
'control',
'required',
'routeId',
'caseId',
'referenceCaseId',
'external',
]);
function assertParity(left: unknown, right: unknown, path: string): void {
if (Array.isArray(left) || Array.isArray(right)) {
expect(Array.isArray(left), path).toBe(true);
expect(Array.isArray(right), path).toBe(true);
expect((left as unknown[]).length, path).toBe((right as unknown[]).length);
(left as unknown[]).forEach((item, index) =>
assertParity(item, (right as unknown[])[index], `${path}[${index}]`),
);
return;
}
if (left && right && typeof left === 'object' && typeof right === 'object') {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
expect(leftKeys, path).toEqual(rightKeys);
for (const key of leftKeys) {
const nextPath = `${path}.${key}`;
const leftValue = (left as Record<string, unknown>)[key];
const rightValue = (right as Record<string, unknown>)[key];
if (STRUCTURAL_KEYS.has(key)) {
expect(leftValue, nextPath).toEqual(rightValue);
} else {
assertParity(leftValue, rightValue, nextPath);
}
}
return;
}
if (
typeof left === 'string' &&
typeof right === 'string' &&
(/featuredCaseIds\[\d+\]$/.test(path) || /options\[\d+\]\.value$/.test(path))
) {
expect(left, path).toBe(right);
}
}
describe('content parity', () => {
it('keeps the German and English trees structurally aligned', () => {
assertParity(SITE_CONTENT_DATA.de, SITE_CONTENT_DATA.en, 'site');
});
it('uses different long-form copy in German and English', () => {
for (const routeId of ROUTE_IDS) {
const german = SITE_CONTENT_DATA.de.pages[routeId];
const english = SITE_CONTENT_DATA.en.pages[routeId];
expect(german.title, routeId).not.toBe(english.title);
expect(german.description, routeId).not.toBe(english.description);
expect(german.hero.headline, routeId).not.toBe(english.hero.headline);
german.sections.forEach((section, index) => {
const other = english.sections[index];
expect(section.body?.join('\n'), `${routeId}.${section.id}`).not.toBe(
other?.body?.join('\n'),
);
});
}
for (const caseId of CASE_STUDY_IDS) {
const german = SITE_CONTENT_DATA.de.cases[caseId];
const english = SITE_CONTENT_DATA.en.cases[caseId];
expect(german.summary, caseId).not.toBe(english.summary);
expect(german.situation, caseId).not.toBe(english.situation);
expect(german.linkLabel, caseId).not.toBe(english.linkLabel);
}
expect(APP_LOCALES).toEqual(['de', 'en']);
});
});

View File

@@ -0,0 +1,230 @@
import { type RouteId } from '../routing/route-ids';
/**
* Copy roles stay separate on purpose:
* - headline is the serious heading used for SEO and page structure
* - proof is only ever a verifiable statement
* - playfulLine is an optional wordplay hook that is never a capability claim
* - cta lives on CtaCopy so action labels stay swappable
*/
export interface CopyBlock {
readonly headline: string;
readonly proof?: string;
readonly playfulLine?: string;
readonly body?: readonly string[];
}
export interface CtaCopy {
readonly label: string;
readonly routeId?: RouteId;
readonly href?: string;
readonly external?: boolean;
readonly fragment?: string;
}
export interface SectionCopy extends CopyBlock {
readonly id: string;
}
export interface PageCopy {
readonly routeId: RouteId;
readonly title: string;
readonly description: string;
readonly hero: CopyBlock;
readonly sections: readonly SectionCopy[];
readonly ctas: readonly CtaCopy[];
}
export type CaseStudyId = 'innofocus' | 'roesterei' | 'myspa' | 'hdi';
export const CASE_STUDY_IDS: readonly CaseStudyId[] = ['innofocus', 'roesterei', 'myspa', 'hdi'];
export interface MetricCopy {
readonly id: string;
readonly value: string;
readonly label: string;
readonly note?: string;
readonly caseId?: CaseStudyId;
}
export interface ProcessStepCopy {
readonly id: string;
readonly title: string;
readonly body: string;
}
export interface CaseStudyCopy {
readonly id: CaseStudyId;
readonly client: string;
readonly role: string;
readonly period: string;
readonly domain: string;
readonly headline: string;
readonly summary: string;
readonly responsibility: string;
readonly situation: string;
readonly approach: readonly string[];
readonly outcome: readonly string[];
readonly metrics: readonly MetricCopy[];
readonly stack: readonly string[];
readonly tags: readonly string[];
readonly linkLabel: string;
readonly transparencyNote?: string;
}
export type OfferingStatus = 'delivered' | 'offer';
export interface OfferingCopy {
readonly id: string;
readonly title: string;
readonly body: string;
readonly status: OfferingStatus;
readonly referenceCaseId?: CaseStudyId;
}
export interface ServiceSequenceCopy {
readonly situation: SectionCopy;
readonly diagnosis: SectionCopy;
readonly implementation: SectionCopy;
readonly operation: SectionCopy;
readonly referenceCaseId: CaseStudyId;
readonly referenceHeadline: string;
readonly referenceNote: string;
readonly inquiry: SectionCopy;
}
export interface ServicePageCopy extends PageCopy {
readonly sequence: ServiceSequenceCopy;
readonly offerings: readonly OfferingCopy[];
readonly deliveredOfferingsHeading: string;
readonly offerOfferingsHeading: string;
readonly offeringCaseBackedLabel: string;
}
export interface HomeServiceAreaCopy {
readonly id: 'workplaces' | 'servers' | 'software' | 'ai';
readonly title: string;
readonly body: string;
readonly routeId: RouteId;
}
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 timeline: readonly PitchTimelineEntryCopy[];
readonly coreStack: readonly StackGroupCopy[];
readonly featuredCaseIds: readonly CaseStudyId[];
readonly timelineHeading: string;
readonly stackHeading: string;
}
export interface ServicesOverviewPageCopy extends PageCopy {
readonly systemsMap: { readonly heading: string; readonly intro: string };
}
export type ContactFieldId =
'name' | 'email' | 'company' | 'projectType' | 'situation' | 'timeframe';
export interface ContactFieldOption {
readonly value: string;
readonly label: string;
}
export interface ContactFieldCopy {
readonly id: ContactFieldId;
readonly control: 'text' | 'email' | 'select' | 'textarea';
readonly label: string;
readonly hint?: string;
readonly required: boolean;
readonly options?: readonly ContactFieldOption[];
}
export interface ContactPageCopy extends PageCopy {
readonly fields: readonly ContactFieldCopy[];
readonly mailSubject: string;
readonly mailIntro: string;
readonly mailSignature: string;
readonly submitLabel: string;
readonly incompleteHint: string;
readonly requiredMarkerLabel: string;
readonly directEmailLabel: string;
readonly calendarLabel: string;
readonly noBackendNote: string;
}
export interface LegalSectionCopy {
readonly id: string;
readonly title: string;
readonly body: readonly string[];
}
export interface LegalPageCopy extends PageCopy {
readonly reviewNotice: string;
readonly reviewTodos: readonly string[];
readonly legalSections: readonly LegalSectionCopy[];
}
export interface StackGroupCopy {
readonly id: string;
readonly title: string;
readonly body?: string;
}
export interface StackPageCopy extends PageCopy {
readonly groups: readonly StackGroupCopy[];
readonly evidenceNote: string;
}
export interface CaseStudyLabels {
readonly situation: string;
readonly approach: string;
readonly outcome: string;
readonly metrics: string;
readonly stack: string;
readonly tags: string;
}
export interface ProjectsPageCopy extends PageCopy {
readonly caseLabels: CaseStudyLabels;
}
export type ServicePageId =
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
export interface SiteSeoCopy {
readonly jobTitle: string;
readonly professionalServiceName: string;
readonly professionalServiceDescription: string;
}
export interface SiteContent {
readonly pages: Record<RouteId, PageCopy>;
readonly home: HomePageCopy;
readonly pitch: PitchPageCopy;
readonly servicesOverview: ServicesOverviewPageCopy;
readonly services: Record<ServicePageId, ServicePageCopy>;
readonly cases: Record<CaseStudyId, CaseStudyCopy>;
readonly contact: ContactPageCopy;
readonly projects: ProjectsPageCopy;
readonly stack: StackPageCopy;
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
readonly seo: SiteSeoCopy;
}

View File

@@ -0,0 +1,72 @@
import { computed, inject, Injectable, type Signal } from '@angular/core';
import { LocaleService } from '../i18n/locale.service';
import { type RouteId } from '../routing/route-ids';
import {
CASE_STUDY_IDS,
type CaseStudyCopy,
type CaseStudyId,
type ContactPageCopy,
type HomePageCopy,
type LegalPageCopy,
type PageCopy,
type PitchPageCopy,
type ProjectsPageCopy,
type ServicePageCopy,
type ServicePageId,
type ServicesOverviewPageCopy,
type StackPageCopy,
} from './content.contracts';
import { SITE_CONTENT } from './content.token';
@Injectable({ providedIn: 'root' })
export class ContentService {
private readonly localeService = inject(LocaleService);
private readonly content = inject(SITE_CONTENT);
page(routeId: RouteId): Signal<PageCopy> {
return computed(() => this.content[this.localeService.locale()].pages[routeId]);
}
home(): Signal<HomePageCopy> {
return computed(() => this.content[this.localeService.locale()].home);
}
pitch(): Signal<PitchPageCopy> {
return computed(() => this.content[this.localeService.locale()].pitch);
}
servicesOverview(): Signal<ServicesOverviewPageCopy> {
return computed(() => this.content[this.localeService.locale()].servicesOverview);
}
service(id: ServicePageId): Signal<ServicePageCopy> {
return computed(() => this.content[this.localeService.locale()].services[id]);
}
caseStudy(id: CaseStudyId): Signal<CaseStudyCopy> {
return computed(() => this.content[this.localeService.locale()].cases[id]);
}
cases(): Signal<readonly CaseStudyCopy[]> {
return computed(() => {
const cases = this.content[this.localeService.locale()].cases;
return CASE_STUDY_IDS.map((id) => cases[id]);
});
}
contact(): Signal<ContactPageCopy> {
return computed(() => this.content[this.localeService.locale()].contact);
}
projects(): Signal<ProjectsPageCopy> {
return computed(() => this.content[this.localeService.locale()].projects);
}
stack(): Signal<StackPageCopy> {
return computed(() => this.content[this.localeService.locale()].stack);
}
legal(id: 'imprint' | 'privacy'): Signal<LegalPageCopy> {
return computed(() => this.content[this.localeService.locale()].legal[id]);
}
}

View File

@@ -0,0 +1,5 @@
import { InjectionToken } from '@angular/core';
import { type AppLocale } from '../i18n/locale';
import { type SiteContent } from './content.contracts';
export const SITE_CONTENT = new InjectionToken<Record<AppLocale, SiteContent>>('SITE_CONTENT');

View File

@@ -0,0 +1,181 @@
import { type CaseStudyCopy } from '../content.contracts';
export const CASES_DE: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
innofocus: {
id: 'innofocus',
client: 'Innofocus / reifen.com',
role: 'Senior Backend- und Datenbankingenieur',
period: 'seit 09/2025, laufend',
domain: 'eCommerce / ERP',
headline: 'Normalisierte ERP-Datenmigration in Microsoft SQL Server',
summary:
'Ersatz des bestehenden ERP durch ein neues, normalisiertes System. Die Migration aller Altdaten läuft direkt in Microsoft SQL Server.',
responsibility:
'Alleinverantwortung: das Migrationsteam besteht aus einer Person. Als einziger Freiberufler steht er im direkten Kontakt mit dem Endkunden.',
situation:
'Das bestehende ERP wird durch ein neues, normalisiertes System ersetzt. Alle Altdaten werden direkt in Microsoft SQL Server migriert. Die Altstruktur ist nicht normalisiert, die Zielstruktur ist es; die Migration besteht deshalb vor allem aus Datenbereinigung, Normalisierung und Dokumentation für eine prüfende Stelle. Die Migration war zuvor bei einem anderen Dienstleister gelaufen und wurde übernommen.',
approach: [
'Umfangreiche T-SQL-Stored-Procedures für Transformation, Normalisierung und Bereinigung.',
'Selektive Arbeit an der Anwendung selbst mit C#, .NET und Angular.',
'Anmeldung über Keycloak mit OAuth 2 / OIDC.',
'Betrieb auf Kubernetes, GitOps-Deployments über Argo CD, Git und Pipelines in Azure DevOps.',
],
outcome: [
'Die Laufzeit der Migration liegt bei etwa 90 Minuten statt zuvor rund acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
'Die Zielstruktur ist normalisiert und für die prüfende Stelle dokumentiert.',
],
metrics: [
{
id: 'innofocus-migration-runtime',
value: '8 h → ca. 90 min',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
caseId: 'innofocus',
},
],
stack: [
'Microsoft SQL Server',
'T-SQL',
'C#',
'.NET',
'Angular',
'Keycloak',
'OAuth 2 / OIDC',
'Kubernetes',
'Argo CD',
'Azure DevOps',
],
tags: ['eCommerce', 'ERP', 'Datenmigration', 'SQL'],
linkLabel: 'Fall lesen: Innofocus / reifen.com',
},
roesterei: {
id: 'roesterei',
client: 'Rösterei Tangermünde',
role: 'Gesamtverantwortung für die Technik',
period: 'seit 10/2023, laufend',
domain: 'eCommerce und interne IT',
headline: 'Laden-IT, Shop und interne Werkzeuge aus einer Hand',
summary:
'Ursprünglich sollte ein neuer Shop entstehen. Der Auftrag ist weit darüber hinausgewachsen und umfasst die gesamte Technik des Unternehmens.',
responsibility:
'Vollständige Verantwortung für die Technik: Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, der neue Onlineshop und neues Marketing.',
situation:
'Die Rösterei brauchte zunächst einen neuen Shop. Daraus wurde die Verantwortung für Netz, Server, Geräte, Microsoft 365, den Shop und Marketing. Anforderungen kommen direkt von nicht-technischen Stakeholdern; die Markenführung entsteht in Zusammenarbeit mit einem Designer.',
approach: [
'Zeitgemäße und kostengünstige Infrastruktur statt unnötiger Komplexität.',
'Shop-Version 1: Shopify mit eigenem Theme und eigenen Komponenten in Liquid.',
'Shop-Version 2: Liquid und Hydrogen, mit eigenen angebundenen Diensten.',
'Interne Werkzeuge für geringere Kosten und mehr Autonomie: ein Generator für Vektorgrafiken in Werbemitteln, ein KI-Werkzeug zur Kategorisierung von Kundenbewertungen und ein KI-Werkzeug für die schnelle Verwaltung des neuen Shops, vergleichbar mit Shopify Sidekick und in die interne Werkzeugkette eingebunden.',
],
outcome: [
'Die Technik des Unternehmens liegt in einer Hand, vom Netz bis zum Shop.',
'Zwei KI-Werkzeuge sind im internen Betrieb der Rösterei im Einsatz: die Kategorisierung von Bewertungen und der Shop-Assistent.',
],
metrics: [],
stack: [
'Shopify',
'Liquid',
'Hydrogen',
'Angular',
'TypeScript',
'SCSS',
'Java Spring WebFlux',
'Java OpenAI library',
'MariaDB',
'Docker',
'Kubernetes',
'Proxmox',
'GitLab CE',
'Microsoft 365',
],
tags: ['eCommerce', 'interne IT', 'Shopify', 'KI-Werkzeuge'],
linkLabel: 'Fall lesen: Rösterei Tangermünde',
transparencyNote:
'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
},
myspa: {
id: 'myspa',
client: 'Aracom IT Services / MySpa',
role: 'Lead Backend / DevOps',
period: '03/2024 09/2024',
domain: 'IoT',
headline: 'Backend als Schnittstelle zwischen Haus, Zahlung und Raumtechnik',
summary:
'Ersatz der Altsoftware durch neue Individualsoftware. Das Backend verbindet Website, internes Admin-Werkzeug, Zahlungsterminal, Schließfächer, smarte Geräte und Touchpanels.',
responsibility:
'Leitung des Backend-Teams (4 Personen) und des DevOps-Teams (2 Personen) bis zum Start der Software. Direkter Kundenkontakt für Anforderungen.',
situation:
'Die vorhandene Software sollte durch eine neue Eigenentwicklung ersetzt werden. Das Backend ist die Schnittstelle zwischen Website, internem Admin-Werkzeug, Zahlungsterminal, Schließfächern, smarten Geräten (Licht, Fernseher) und Touchpanels für Bestellungen und Raumtechnik.',
approach: [
'Backend mit C# und .NET 8, REST-API, Entity Framework 8 im Data-First-Ansatz.',
'Verwaltung des MySQL-Servers vor Ort.',
'Testgetriebene Entwicklung mit Unit- und Integrationstests (xUnit).',
'OAuth 2 über ein Identity-Provider-Framework, dazu MQTT und Hangfire.',
'Migration von Entwicklungs-, QS- und Produktivsystemen von Diensten vor Ort in ein Docker-Cluster.',
'CI/CD-Pipelines mit GitLab EE, Arbeit im Scrum.',
],
outcome: [
'Das Backend verbindet die genannten Kanäle bis zum Softwarestart.',
'Entwicklungs-, QS- und Produktivsysteme laufen im Docker-Cluster.',
],
metrics: [],
stack: [
'C#',
'.NET 8',
'ASP.NET Core',
'Entity Framework 8',
'xUnit',
'MySQL',
'OAuth 2',
'MQTT',
'Hangfire',
'Docker',
'GitLab EE',
],
tags: ['IoT', 'Backend', 'DevOps', 'Docker'],
linkLabel: 'Fall lesen: Aracom IT Services / MySpa',
},
hdi: {
id: 'hdi',
client: 'HDI Specialty',
role: 'Senior Fullstack / DevOps',
period: '05/2023 01/2024',
domain: 'Versicherung',
headline: 'Exposure Management für Simulationen und BaFin-Auswertungen',
summary:
'Software, die Simulationsfolgen automatisiert, um bestimmte Risiken zu bestimmen, Simulationen stochastisch auswertet, Policen zentral sammelt und Auswertungen aller Policen für die BaFin erzeugt.',
responsibility:
'Senior-Fullstack- und DevOps-Arbeit an der Exposure-Management-Software, einschließlich der Migration der Umgebungen auf Azure AKS.',
situation:
'HDI Specialty braucht Software für Exposure Management: Simulationsfolgen sollen bestimmte Risiken bestimmen, stochastisch ausgewertet werden, Policen zentral sammeln und Auswertungen aller Policen für die BaFin liefern.',
approach: [
'Angular-Einzelanwendung mit TypeScript, HTML, SCSS/CSS, Ngrx Store und Ngx Pipes.',
'Backend mit C# und .NET, REST-API und Entity Framework.',
'Testgetriebene Entwicklung mit Unit- und Integrationstests (xUnit).',
'Verwaltung von Azure SQL Server.',
'Anmeldung und Berechtigungen über Okta mit OAuth 2.',
'CI/CD-Pipelines und Projektsteuerung mit Azure DevOps, Arbeit im Scrum.',
],
outcome: [
'Entwicklungs-, QS- und Produktivsysteme wurden von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS migriert.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'Ngrx',
'Ngx Pipes',
'C#',
'.NET',
'Entity Framework',
'xUnit',
'Azure SQL Server',
'Okta',
'OAuth 2',
'Azure AKS',
'Azure DevOps',
],
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure'],
linkLabel: 'Fall lesen: HDI Specialty',
},
};

View File

@@ -0,0 +1,93 @@
import { type HomePageCopy } from '../content.contracts';
export const HOME_DE: HomePageCopy = {
routeId: 'home',
title: 'Startseite | Antonio Ledebuhr',
description:
'Du beschreibst, was Dein Unternehmen erreichen soll. Ein technischer Ansprechpartner übernimmt den Weg von der Diagnose bis zum Betrieb oder zur Übergabe.',
hero: {
headline:
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
proof:
'Bei der Rösterei Tangermünde reicht die öffentliche Arbeit vom Netz und den Arbeitsplätzen bis zum Shop und zwei internen KI-Werkzeugen.',
playfulLine: 'IT mit Drehmoment',
body: [
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ein Ansprechpartner führt von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
],
},
serviceAreasHeading: 'Wobei ich helfen kann',
serviceAreas: [
{
id: 'workplaces',
title: 'Arbeitsplätze und Netz',
body: 'Geräte, Netz und die Dienste, mit denen Dein Team täglich arbeitet, bleiben zuverlässig.',
routeId: 'servicesHardwareNetwork',
},
{
id: 'servers',
title: 'Server und Cluster',
body: 'Einzelne Hosts, Virtualisierung oder ein Cluster. Der Weg dorthin bleibt nachvollziehbar, vor Ort oder auf Azure AKS.',
routeId: 'servicesClusters',
},
{
id: 'software',
title: 'Software und Daten',
body: 'Produktsoftware und Datenarbeit: von der Fachlichkeit über die API bis zur Oberfläche, inklusive SQL.',
routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'KI-Abläufe',
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine — lokale Modelle, Suche in eigenen Unterlagen, Prüfung durch einen Menschen — werden je Auftrag geprüft.',
routeId: 'servicesAi',
},
],
processHeading: 'So läuft die Zusammenarbeit',
process: [
{
id: 'describe',
title: 'Lage in Deinen Worten',
body: 'Du beschreibst das Ziel, den Schmerz oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
},
{
id: 'diagnose',
title: 'Technische Diagnose',
body: 'Ich ordne die Lage den vier Bereichen zu, nenne die Grenze und sage, was außerhalb liegt.',
},
{
id: 'implement',
title: 'Umsetzung',
body: 'Ein Ansprechpartner setzt um: Software, Infrastruktur, Cluster oder ein klar umrissenes KI-Werkzeug, je nach Briefing.',
},
{
id: 'operate',
title: 'Betrieb oder Übergabe',
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen, kein undokumentierter Zwischenstand.',
},
],
proofCaseId: 'roesterei',
proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
proofNote: 'Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
sections: [
{
id: 'scope',
headline: 'Vier Arbeitsbereiche',
body: [
'Vier Bereiche: Arbeitsplätze und Netz, Server und Cluster, Software und Daten sowie KI-Abläufe. Was nicht dazu gehört, wird im Briefing benannt — nicht stillschweigend mitverkauft.',
],
},
{
id: 'proof',
headline: 'Warum die Rösterei als Beleg steht',
body: [
'Die Rösterei Tangermünde ist der öffentliche Fall, der die Kette vom Netz bis zum Shop zeigt. Die übrigen drei Fälle stützen einzelne Bereiche.',
],
},
],
ctas: [
{ label: 'Projektbriefing starten', routeId: 'contact' },
{ label: 'Leistungen ansehen', routeId: 'services' },
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
],
};

View File

@@ -0,0 +1,374 @@
import { SITE_CONFIG } from '../site-config';
import {
type ContactPageCopy,
type LegalPageCopy,
type PageCopy,
type ProjectsPageCopy,
type StackPageCopy,
} from '../content.contracts';
export const PROJECTS_DE: ProjectsPageCopy = {
routeId: 'projects',
title: 'Projekte | Antonio Ledebuhr',
description:
'Vier öffentliche Fälle: Innofocus / reifen.com, Rösterei Tangermünde, MySpa und HDI Specialty. Eine bewusst schmale Auswahl, der vollständige Lebenslauf liegt als Download bereit.',
hero: {
headline: 'Vier öffentliche Fälle, bewusst ausgewählt',
proof:
'Die Seite zeigt eine kuratierte Auswahl. Der vollständige Lebenslauf steht als PDF zum Download bereit.',
},
sections: [
{
id: 'selection',
headline: 'Warum nur diese vier',
body: [
'Öffentlich stehen vier Fälle: Innofocus / reifen.com, die Rösterei Tangermünde, MySpa und HDI Specialty. Weitere Stationen gehören in den Lebenslauf, nicht auf diese Seiten.',
],
},
{
id: 'reading',
headline: 'Was jeder Fall festhält',
body: [
'Jeder Fall dokumentiert Auftrag, Rolle, Zeitraum, Lage, Vorgehen und Ergebnis. Zahlen stehen nur dort, wo sie belegt sind.',
],
},
],
ctas: [
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Kontakt', routeId: 'contact' },
],
caseLabels: {
situation: 'Lage',
approach: 'Vorgehen',
outcome: 'Ergebnis',
metrics: 'Kennzahlen',
stack: 'Technik',
tags: 'Schlagworte',
},
};
export const STACK_DE: StackPageCopy = {
routeId: 'stack',
title: 'Technik-Stack | Antonio Ledebuhr',
description:
'Technik-Überblick in sieben Gruppen: Programmierung, Datenbanken, DevOps, Betriebssysteme, Infrastructure as Code, Hypervisor und Werkzeuge.',
hero: {
headline: 'Ein Technik-Überblick zur Einordnung',
proof:
'Die Rasterkarte ist eine Vertrautheitsliste. Sie zeigt Technologien aus der Arbeit, ohne jeden Eintrag als öffentlichen Fall zu belegen.',
},
sections: [
{
id: 'reading',
headline: 'Überblick, keine Rangliste',
body: [
'Die Übersicht ordnet Technologien nach Gruppen. Sie ist keine Rangliste und kein Nachweis, dass jeder Eintrag in einem öffentlichen Fall vorkommt.',
],
},
{
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.',
],
},
],
ctas: [
{ label: 'Projekte', routeId: 'projects' },
{ label: 'Über mich', routeId: 'about' },
],
groups: [
{ id: 'programming', title: 'Programmierung' },
{ id: 'db', title: 'Datenbanken' },
{ id: 'devops', title: 'DevOps' },
{ id: 'os', title: 'Betriebssysteme' },
{ id: 'iac', title: 'Infrastructure as Code' },
{ id: 'hyperviser', title: 'Hypervisor' },
{ id: 'tools', title: 'Werkzeuge' },
],
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.',
};
export const ABOUT_DE: PageCopy = {
routeId: 'about',
title: 'Über mich | Antonio Ledebuhr',
description:
'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur in Tangermünde. Freelance seit 04/2023, Deutsch als Muttersprache, Englisch verhandlungssicher.',
hero: {
headline: 'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur',
proof:
'Sitz in Tangermünde. Freelance seit 04/2023. Deutsch als Muttersprache, Englisch verhandlungssicher.',
},
sections: [
{
id: 'profile',
headline: 'Berufliches Profil',
body: [
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Heimspiel ist Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Dazu kommen C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) und Angular (TypeScript, RxJS-zeitige SPA-Arbeit, Ngrx, SCSS).',
],
},
{
id: 'working',
headline: 'Arbeitsweise',
body: [
'Sauberer Code, tragfähige Architektur, Domain-driven Design und testgetriebene Entwicklung. Neu in eine Fachlichkeit einsteigen gehört zur Arbeit, nicht an ihren Rand.',
],
},
{
id: 'contact-and-language',
headline: 'Kundenkontakt, Team und Sprachen',
body: [
'Direkter Kundenkontakt und Teamverantwortung sind Teil der bisherigen Arbeit, nicht eine spätere Ausbaustufe.',
'Deutsch ist Muttersprache. Englisch ist verhandlungssicher.',
],
},
{
id: 'accent',
headline: 'Rösterei und Drehmoment',
body: [
'Die Rösterei-Arbeit bleibt der persönliche Akzent: Rösten und Drehmoment statt Leerlauf. Antonio Ledebuhr hält eine wirtschaftliche Beteiligung an der Rösterei Tangermünde.',
],
},
],
ctas: [
{ label: 'Projekte', routeId: 'projects' },
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
{ label: 'Kontakt', routeId: 'contact' },
],
};
export const CONTACT_DE: ContactPageCopy = {
routeId: 'contact',
title: 'Kontakt | Antonio Ledebuhr',
description:
'Kurzes Projektbriefing im Browser. Es entsteht nur eine lokale E-Mail an info@antoniolede.de, ohne Versand an diesen Server.',
hero: {
headline: 'Kurzes Briefing, danach das lokale E-Mail-Programm',
proof:
'Die Felder bleiben im Browser. Der Knopf öffnet nur eine vorausgefüllte Nachricht an info@antoniolede.de.',
},
sections: [
{
id: 'how',
headline: 'Was mit den Angaben passiert',
body: [
'Nichts wird an diesen Server gesendet. Aus den ausgefüllten Feldern entsteht eine mailto-Nachricht, die das lokale E-Mail-Programm öffnet.',
],
},
{
id: 'direct',
headline: 'Direkt schreiben',
body: [
'Wer das Briefing nicht nutzen möchte, schreibt direkt an info@antoniolede.de. Ein Kalenderlink ist derzeit nicht hinterlegt.',
],
},
],
ctas: [],
fields: [
{ id: 'name', control: 'text', label: 'Name', required: true },
{ id: 'email', control: 'email', label: 'E-Mail', required: true },
{ id: 'company', control: 'text', label: 'Unternehmen', hint: 'freiwillig', required: false },
{
id: 'projectType',
control: 'select',
label: 'Art des Vorhabens',
hint: 'freiwillig — wenn noch unklar, einfach offen lassen oder „Noch unklar“ wählen',
required: false,
options: [
{ value: 'unsure', label: 'Noch unklar' },
{ value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware und Netzwerk' },
{ value: 'clusters', label: 'Cluster' },
{ value: 'ai', label: 'KI-Integration' },
{ value: 'other', label: 'Sonstiges' },
],
},
{
id: 'situation',
control: 'textarea',
label: 'Lage',
hint: 'Was steht an, und woran soll die Zusammenarbeit ansetzen?',
required: true,
},
{ id: 'timeframe', control: 'text', label: 'Zeitrahmen', hint: 'freiwillig', required: false },
],
mailSubject: 'Projektanfrage über antoniolede.de',
mailIntro: 'Kurzes Briefing von der Website:',
mailSignature: 'Gesendet über das Kontaktbriefing auf antoniolede.de.',
submitLabel: 'E-Mail-Programm öffnen',
incompleteHint: 'Bitte noch ausfüllen:',
requiredMarkerLabel: 'Pflichtfeld',
directEmailLabel: 'Direkt an info@antoniolede.de schreiben',
calendarLabel: 'Termin finden',
noBackendNote:
'Es gibt kein Formular-Backend. Es wird nichts an diesen Server übertragen. Der Knopf öffnet nur das lokale E-Mail-Programm.',
};
export const IMPRINT_DE: LegalPageCopy = {
routeId: 'imprint',
title: 'Impressum | Antonio Ledebuhr',
description:
'Anbieterkennzeichnung von Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde. Der Text ist ungeprüft und vor der Veröffentlichung vom Seitenbetreiber zu prüfen.',
hero: {
headline: 'Impressum',
proof:
'Name, Anschrift und E-Mail sind aus dem Lebenslauf übernommen. Weitere Pflichtangaben fehlen bewusst.',
},
sections: [
{
id: 'scope',
headline: 'Was hier steht',
body: [
'Dieser Text nennt Name, Anschrift, E-Mail und die Verantwortung für den Inhalt. Er ist kein fertiges Impressum.',
],
},
{
id: 'limit',
headline: 'Was fehlt',
body: [
'Umsatzsteuer-Identifikationsnummer, Registerdaten, Aufsicht, Berufshaftpflicht und Telefon stehen nicht auf dieser Seite, weil sie hier nicht belegt sind.',
],
},
],
ctas: [{ label: 'Datenschutz', routeId: 'privacy' }],
reviewNotice:
'Achtung: Dieser Rechtstext ist ungeprüft. Vor jeder Veröffentlichung muss der Seitenbetreiber ihn prüfen und die offenen Punkte unten ergänzen.',
reviewTodos: [
'Umsatzsteuer-Identifikationsnummer, sofern eine besteht',
'Registergericht und Registernummer, sofern ein Eintrag besteht',
'Aufsichtsbehörde, sofern eine zuständig ist',
'Berufshaftpflichtversicherung, sofern eine besteht',
'Telefonnummer, sofern sie veröffentlicht werden soll',
'Hosting-Anbieter und dessen Datenverarbeitung',
],
legalSections: [
{
id: 'provider',
title: 'Anbieter',
body: [
'Antonio Ledebuhr',
'Kirchstr. 19',
'39590 Tangermünde',
'Germany',
'E-Mail: info@antoniolede.de',
],
},
{
id: 'responsibility',
title: 'Verantwortlich für den Inhalt',
body: [
'Verantwortlich für den Inhalt dieser Seiten ist Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany.',
],
},
{
id: 'completeness',
title: 'Keine Vollständigkeit',
body: [
'Diese Seite behauptet keine rechtliche Vollständigkeit. Offene Angaben stehen in der Prüfungsliste oben und müssen vor der Veröffentlichung vom Seitenbetreiber ergänzt werden.',
],
},
],
};
export const PRIVACY_DE: LegalPageCopy = {
routeId: 'privacy',
title: 'Datenschutz | Antonio Ledebuhr',
description:
'Hinweise zur Verarbeitung auf dieser statischen, serverseitig gerenderten Website ohne eigenes Backend. Der Text ist ungeprüft und vor der Veröffentlichung zu ergänzen.',
hero: {
headline: 'Datenschutz',
proof:
'Die Website hat kein eigenes Backend. Das Kontaktbriefing bleibt im Browser und erzeugt nur eine mailto-Nachricht.',
},
sections: [
{
id: 'scope',
headline: 'Was dieser Text leistet',
body: [
'Er beschreibt den öffentlichen Stand der Seite, so weit er hier belegt ist. Er ist keine abgeschlossene Datenschutzerklärung.',
],
},
{
id: 'limit',
headline: 'Was der Seitenbetreiber noch eintragen muss',
body: [
'Hosting-Anbieter, dessen Verarbeitung und weitere gesetzlich geforderte Punkte stehen in der Prüfungsliste und fehlen hier bewusst.',
],
},
],
ctas: [{ label: 'Impressum', routeId: 'imprint' }],
reviewNotice:
'Achtung: Dieser Rechtstext ist ungeprüft. Vor jeder Veröffentlichung muss der Seitenbetreiber ihn prüfen und die offenen Punkte unten ergänzen.',
reviewTodos: [
'Hosting-Anbieter und dessen Auftragsverarbeitung',
'Serverstandort und eingesetzte Unterauftragsverarbeiter',
'Kontaktmöglichkeit für datenschutzrechtliche Anfragen, sofern nicht die E-Mail genügt',
'Weitere gesetzlich geforderte Angaben, sobald sie feststehen',
],
legalSections: [
{
id: 'controller',
title: 'Verantwortlicher',
body: [
'Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany, E-Mail: info@antoniolede.de.',
],
},
{
id: 'site-nature',
title: 'Art dieser Website',
body: [
'Diese Website ist eine statische, serverseitig gerenderte Website ohne eigenes Backend. Es gibt kein Nutzerkonto und keine serverseitige Formularannahme auf dieser Domain.',
],
},
{
id: 'contact-briefing',
title: 'Kontaktbriefing',
body: [
'Das Kontaktbriefing läuft vollständig im Browser. Es setzt nur einen mailto-Link zusammen. Es werden keine Formulardaten an diese Website übertragen.',
],
},
{
id: 'email',
title: 'E-Mail-Korrespondenz',
body: [
'Wenn eine Nachricht an info@antoniolede.de geht, wird die Korrespondenz zum Zweck der Anfrage verarbeitet. Eine andere Nutzung ist hier nicht beschrieben.',
],
},
{
id: 'rights',
title: 'Rechte betroffener Personen',
body: [
'Betroffene Personen haben die allgemeinen Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Widerspruch und Beschwerde bei einer Aufsichtsbehörde. Die konkrete Ausübung hängt vom jeweiligen Vorgang ab.',
],
},
{
id: 'hosting',
title: 'Hosting',
body: [
'Der Hosting-Anbieter und seine Verarbeitung sind vom Seitenbetreiber noch einzutragen. An dieser Stelle wird kein Anbieter genannt.',
],
},
],
};
export const NOT_FOUND_DE: PageCopy = {
routeId: 'notFound',
title: 'Seite nicht gefunden | Antonio Ledebuhr',
description:
'Diese Adresse führt auf keine öffentliche Seite. Weiter geht es über Start, Leistungen, Projekte oder Kontakt.',
hero: {
headline: 'Diese Seite gibt es hier nicht',
proof:
'Die Adresse gehört zu keiner öffentlichen Route. Die vier Links unten führen zurück in den Bestand.',
},
sections: [],
ctas: [
{ label: 'Zur Startseite', routeId: 'home' },
{ label: 'Leistungen', routeId: 'services' },
{ label: 'Projekte', routeId: 'projects' },
{ label: 'Kontakt', routeId: 'contact' },
],
};

View File

@@ -0,0 +1,133 @@
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: rund sieben Jahre Webanwendungen, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
proof:
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen. Freiberuflich seit 04/2023.',
playfulLine: 'IT mit Drehmoment',
},
profile: [
'Rund sieben Jahre berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen.',
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'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.',
],
metrics: [
{
id: 'experience-years',
value: 'ca. 7 Jahre',
label: 'berufliche Erfahrung mit Webanwendungen',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → ca. 90 min',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'Leitung des Backend-Teams und des DevOps-Teams bis zum Start von MySpa',
caseId: 'myspa',
},
],
timelineHeading: 'Stationen',
timeline: [
{
id: 'self-employed',
period: 'seit 12/2021',
role: 'unternehmerisch selbständig',
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb; eigene Rechnungsstellung und Kundenbeziehungen.',
},
{
id: 'freelance',
period: 'seit 04/2023',
role: 'freiberuflich',
body: 'Freiberufliche 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.',
},
{
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: 'myspa',
period: '03/2024 09/2024',
role: 'Lead Backend / DevOps bei Aracom IT Services / MySpa',
body: 'Führung von Backend und DevOps bis zum Softwarestart, Anforderungen direkt vom Kunden.',
},
{
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 Kontakt mit dem Endkunden.',
},
],
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', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'In dreißig Sekunden',
body: [
'Antonio Ledebuhr arbeitet als Fullstack- und DevOps-Ingenieur aus Tangermünde. Der Lebenslauf als PDF führt die Stationen vollständig.',
],
},
{
id: 'featured-cases',
headline: 'Ausgewählte Fälle',
body: [
'Vier Fälle decken eCommerce und ERP, Laden-IT, IoT und Versicherung 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}` },
],
};

View File

@@ -0,0 +1,440 @@
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_DE: ServicesOverviewPageCopy = {
routeId: 'services',
title: 'Leistungen | Antonio Ledebuhr',
description:
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
hero: {
headline: 'Vier Leistungsbereiche, ein Ansprechpartner',
proof:
'Software, Hardware und Netzwerk, Cluster sowie KI-Integration. Der Weg geht von der Lage über die Diagnose zur Umsetzung und in den Betrieb.',
playfulLine: 'Systeme, die nicht im Leerlauf laufen',
},
sections: [
{
id: 'areas',
headline: 'Die vier Bereiche',
body: [
'Software umfasst Fullstack-Produktarbeit mit Java und Spring Boot, C# und .NET, Angular, REST-APIs, Domain-driven Design, testgetriebener Entwicklung sowie SQL- und T-SQL-Datenarbeit.',
'Hardware und Netzwerk umfasst Netz- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365 sowie Server und Virtualisierung vor Ort.',
'Cluster umfasst Docker und Kubernetes on-premise und auf Azure AKS, die Migration von einzelnen Hosts und PaaS auf Cluster, GitLab CI/CD und Azure DevOps sowie GitOps mit Argo CD.',
'KI-Integration ist der größte Bereich: zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde und weitere Bausteine, die je Auftrag geschnitten und geprüft werden.',
],
},
{
id: 'who',
headline: 'Für wen das gedacht ist',
body: [
'Für Unternehmen, die einen Ansprechpartner vom ersten Anforderungsworkshop bis in den Produktivbetrieb suchen — ohne extra Schicht zwischen Fachseite und Technik.',
'Für Recruiterinnen und Recruiter, die die öffentliche Auswahl und den Stack prüfen wollen, bevor sie den Lebenslauf lesen.',
],
},
{
id: 'engagement',
headline: 'So läuft eine Zusammenarbeit',
body: [
'Zuerst die Lage, dann die Diagnose, danach die Umsetzung und zum Schluss Betrieb oder Übergabe. Jede Detailseite folgt dieser Reihenfolge und nennt einen passenden öffentlichen Fall.',
],
},
],
ctas: [
{ label: 'Software', routeId: 'servicesSoftware' },
{ label: 'Hardware und Netzwerk', routeId: 'servicesHardwareNetwork' },
{ label: 'Cluster', routeId: 'servicesClusters' },
{ label: 'KI-Integration', routeId: 'servicesAi' },
{ label: 'Projekt anfragen', routeId: 'contact' },
],
systemsMap: {
heading: 'Wie die Schichten zusammenhängen',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
},
};
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',
},
],
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
};

View File

@@ -0,0 +1,62 @@
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,
IMPRINT_DE,
NOT_FOUND_DE,
PRIVACY_DE,
PROJECTS_DE,
STACK_DE,
} from './pages';
import {
SERVICES_AI_DE,
SERVICES_CLUSTERS_DE,
SERVICES_HARDWARE_DE,
SERVICES_OVERVIEW_DE,
SERVICES_SOFTWARE_DE,
} from './services';
export const SITE_CONTENT_DE: SiteContent = {
home: HOME_DE,
pitch: PITCH_DE,
servicesOverview: SERVICES_OVERVIEW_DE,
services: {
servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
servicesClusters: SERVICES_CLUSTERS_DE,
servicesAi: SERVICES_AI_DE,
},
cases: CASES_DE,
contact: CONTACT_DE,
projects: PROJECTS_DE,
stack: STACK_DE,
legal: {
imprint: IMPRINT_DE,
privacy: PRIVACY_DE,
},
seo: {
jobTitle: 'Fullstack- und DevOps-Ingenieur',
professionalServiceName: 'Software- und DevOps-Leistungen',
professionalServiceDescription:
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
},
pages: {
home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_OVERVIEW_DE,
servicesSoftware: SERVICES_SOFTWARE_DE,
servicesHardwareNetwork: SERVICES_HARDWARE_DE,
servicesClusters: SERVICES_CLUSTERS_DE,
servicesAi: SERVICES_AI_DE,
projects: PROJECTS_DE,
stack: STACK_DE,
about: ABOUT_DE,
contact: CONTACT_DE,
imprint: IMPRINT_DE,
privacy: PRIVACY_DE,
notFound: NOT_FOUND_DE,
},
};

View File

@@ -0,0 +1,180 @@
import { type CaseStudyCopy } from '../content.contracts';
export const CASES_EN: Record<'innofocus' | 'roesterei' | 'myspa' | 'hdi', CaseStudyCopy> = {
innofocus: {
id: 'innofocus',
client: 'Innofocus / reifen.com',
role: 'Senior backend and database engineer',
period: 'since 09/2025, ongoing',
domain: 'eCommerce / ERP',
headline: 'Normalised ERP data migration inside Microsoft SQL Server',
summary:
'Replacement of the existing ERP with a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server.',
responsibility:
'Sole responsibility: the migration team is one person. As the only freelancer he is in direct contact with the end customer.',
situation:
'The existing ERP is being replaced by a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server. The legacy structure is not normalised; the target structure is. The migration is therefore largely data cleansing, normalisation and documentation for an auditor. The migration had previously been run by another service provider and was taken over.',
approach: [
'Extensive T-SQL stored procedures for transformation, normalisation and cleansing.',
'Selective work on the application itself with C#, .NET and Angular.',
'Sign-in through Keycloak with OAuth 2 / OIDC.',
'Operation on Kubernetes, GitOps deployments via Argo CD, Git and pipelines on Azure DevOps.',
],
outcome: [
'Migration run time is about 90 minutes instead of around eight hours, with a larger feature scope and higher data quality.',
'The target structure is normalised and documented for the auditor.',
],
metrics: [
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'with a larger feature scope and higher data quality',
caseId: 'innofocus',
},
],
stack: [
'Microsoft SQL Server',
'T-SQL',
'C#',
'.NET',
'Angular',
'Keycloak',
'OAuth 2 / OIDC',
'Kubernetes',
'Argo CD',
'Azure DevOps',
],
tags: ['eCommerce', 'ERP', 'data migration', 'SQL'],
linkLabel: 'Read the case: Innofocus / reifen.com',
},
roesterei: {
id: 'roesterei',
client: 'Rösterei Tangermünde',
role: 'Full technical responsibility for the company',
period: 'since 10/2023, ongoing',
domain: 'eCommerce and internal IT',
headline: 'Shop-floor IT, storefront and internal tools under one roof',
summary:
'The original goal was a new shop. The scope grew far beyond that and now covers the companys technology as a whole.',
responsibility:
'Full responsibility for the technology: network structures, server structures, employee devices, management SaaS such as Microsoft 365, the new online shop and new marketing.',
situation:
'The roastery first needed a new shop. That became responsibility for the network, servers, devices, Microsoft 365, the shop and marketing. Requirements come directly from non-technical stakeholders; brand identity is shaped together with a designer.',
approach: [
'Contemporary and cost-effective infrastructure instead of extra complexity.',
'Shop version 1: Shopify with a custom theme and custom components in Liquid.',
'Shop version 2: Liquid and Hydrogen, with own services integrated.',
'Internal tools for cost reduction and autonomy: a generator for vector graphics used in advertising material, an AI tool that categorises customer reviews, and an AI tool for fast management of the new shop, comparable to Shopify Sidekick and wired into the internal tooling.',
],
outcome: [
'The companys technology sits with one owner, from the network to the shop.',
'Two AI tools are in use in the roasterys internal operations: review categorisation and the shop assistant.',
],
metrics: [],
stack: [
'Shopify',
'Liquid',
'Hydrogen',
'Angular',
'TypeScript',
'SCSS',
'Java Spring WebFlux',
'Java OpenAI library',
'MariaDB',
'Docker',
'Kubernetes',
'Proxmox',
'GitLab CE',
'Microsoft 365',
],
tags: ['eCommerce', 'internal IT', 'Shopify', 'AI tools'],
linkLabel: 'Read the case: Rösterei Tangermünde',
transparencyNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
},
myspa: {
id: 'myspa',
client: 'Aracom IT Services / MySpa',
role: 'Lead backend / DevOps',
period: '03/2024 09/2024',
domain: 'IoT',
headline: 'A backend between the house, payments and room technology',
summary:
'Replacement of legacy software with new custom software. The backend is the interface between the website, the internal admin tool, the payment terminal, lockers, smart devices and touch panels.',
responsibility:
'Took over leadership of the backend team (4 people) and the DevOps team (2 people) until the software launch. Direct customer contact for requirements.',
situation:
'The existing software was to be replaced by a new custom system. The backend is the interface between the website, the internal admin tool, the payment terminal, lockers, smart devices (lights, TVs) and touch panels for placing orders and controlling room technology.',
approach: [
'Backend with C# and .NET 8, a RESTful API, and Entity Framework 8 in a data-first approach.',
'On-prem MySQL server administration.',
'Test-driven development with unit and integration tests (xUnit).',
'OAuth 2 via an identity provider framework, plus MQTT and Hangfire.',
'Migration of development, QA and production systems from on-prem services to a Docker cluster.',
'CI/CD pipelines with GitLab EE, work in Scrum.',
],
outcome: [
'The backend connects the channels listed above through to the software launch.',
'Development, QA and production systems run on the Docker cluster.',
],
metrics: [],
stack: [
'C#',
'.NET 8',
'ASP.NET Core',
'Entity Framework 8',
'xUnit',
'MySQL',
'OAuth 2',
'MQTT',
'Hangfire',
'Docker',
'GitLab EE',
],
tags: ['IoT', 'backend', 'DevOps', 'Docker'],
linkLabel: 'Read the case: Aracom IT Services / MySpa',
},
hdi: {
id: 'hdi',
client: 'HDI Specialty',
role: 'Senior fullstack / DevOps',
period: '05/2023 01/2024',
domain: 'Insurance',
headline: 'Exposure management for simulations and BaFin reports',
summary:
'Software that automates simulation sequences to determine specific risks, evaluates simulations stochastically, collects policies centrally and produces evaluations of all policies for BaFin.',
responsibility:
'Senior fullstack and DevOps work on the exposure-management software, including the migration of the environments onto Azure AKS.',
situation:
'HDI Specialty needs software for exposure management: simulation sequences should determine specific risks, be evaluated stochastically, collect policies centrally and produce evaluations of all policies for BaFin.',
approach: [
'Angular single-page application with TypeScript, HTML, SCSS/CSS, Ngrx Store and Ngx Pipes.',
'Backend with C# and .NET, a RESTful API and Entity Framework.',
'Test-driven development with unit and integration tests (xUnit).',
'Azure SQL Server administration.',
'Authentication and authorisation through Okta with OAuth 2.',
'CI/CD pipelines and project management with Azure DevOps, work in Scrum.',
],
outcome: [
'Development, QA and production systems were migrated from Azure App Services to a Kubernetes cluster on Azure AKS.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'Ngrx',
'Ngx Pipes',
'C#',
'.NET',
'Entity Framework',
'xUnit',
'Azure SQL Server',
'Okta',
'OAuth 2',
'Azure AKS',
'Azure DevOps',
],
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure'],
linkLabel: 'Read the case: HDI Specialty',
},
};

View File

@@ -0,0 +1,93 @@
import { type HomePageCopy } from '../content.contracts';
export const HOME_EN: HomePageCopy = {
routeId: 'home',
title: 'Home | Antonio Ledebuhr',
description:
'You describe what the business needs to achieve. One technical contact takes the path from diagnosis through delivery to operations or handover.',
hero: {
headline:
'You describe what the business needs to achieve. I take the technical path from there.',
proof:
'At Rösterei Tangermünde the public work runs from the network and workplaces through to the shop and two in-house AI tools.',
playfulLine: 'Full-stack, full throttle',
body: [
'You put the outcome, the bottleneck or the recurring process in your own words. One person then handles diagnosis, implementation and operations or handover.',
],
},
serviceAreasHeading: 'What I can help with',
serviceAreas: [
{
id: 'workplaces',
title: 'Workplaces and network',
body: 'Devices, the network and the everyday services your team uses stay reliable.',
routeId: 'servicesHardwareNetwork',
},
{
id: 'servers',
title: 'Servers and clusters',
body: 'A single host, virtualisation or a cluster. The path there stays clear, on-premise or on Azure AKS.',
routeId: 'servicesClusters',
},
{
id: 'software',
title: 'Software and data',
body: 'Product software and data work: from the domain through the API to the interface, including SQL.',
routeId: 'servicesSoftware',
},
{
id: 'ai',
title: 'AI workflows',
body: 'Two delivered tools at Rösterei Tangermünde. Further building blocks — local models, search over your own documents, review by a person — are agreed for each assignment.',
routeId: 'servicesAi',
},
],
processHeading: 'How the work runs',
process: [
{
id: 'describe',
title: 'The situation in your words',
body: 'You describe the goal, the pain or the recurring process. Technical language is not required.',
},
{
id: 'diagnose',
title: 'Technical diagnosis',
body: 'I map the brief onto the four areas, name the boundary and say what sits outside it.',
},
{
id: 'implement',
title: 'Implementation',
body: 'One person delivers: software, infrastructure, a cluster or a clearly defined AI tool, according to the brief.',
},
{
id: 'operate',
title: 'Operations or handover',
body: 'The result stays operable. Handover means structures you can follow, not an undocumented snapshot.',
},
],
proofCaseId: 'roesterei',
proofHeading: 'A verified path from the network to the shop',
proofNote: 'Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
featuredCaseIds: ['innofocus', 'myspa', 'hdi'],
sections: [
{
id: 'scope',
headline: 'Four areas of work',
body: [
'Four areas: workplaces and network, servers and clusters, software and data, and AI workflows. What sits outside that set is named in the briefing — it is not sold by implication.',
],
},
{
id: 'proof',
headline: 'Why the roastery is the proof',
body: [
'Rösterei Tangermünde is the public case that shows the chain from the network to the shop. The other three cases support individual areas.',
],
},
],
ctas: [
{ label: 'Start a project briefing', routeId: 'contact' },
{ label: 'Browse services', routeId: 'services' },
{ label: 'Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
],
};

View File

@@ -0,0 +1,374 @@
import { SITE_CONFIG } from '../site-config';
import {
type ContactPageCopy,
type LegalPageCopy,
type PageCopy,
type ProjectsPageCopy,
type StackPageCopy,
} from '../content.contracts';
export const PROJECTS_EN: ProjectsPageCopy = {
routeId: 'projects',
title: 'Projects | Antonio Ledebuhr',
description:
'Four public cases: Innofocus / reifen.com, Rösterei Tangermünde, MySpa and HDI Specialty. A deliberately narrow selection; the full curriculum vitae is available as a download.',
hero: {
headline: 'Four public cases, deliberately chosen',
proof:
'The site shows a curated selection. The full curriculum vitae is available as a PDF download.',
},
sections: [
{
id: 'selection',
headline: 'Why only these four',
body: [
'The public record has four cases: Innofocus / reifen.com, Rösterei Tangermünde, MySpa and HDI Specialty. Further stations belong in the curriculum vitae, not on these pages.',
],
},
{
id: 'reading',
headline: 'What each case documents',
body: [
'Each case documents the engagement, the role, the period, the situation, the approach and the outcome. Figures appear only where they are verified.',
],
},
],
ctas: [
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Contact', routeId: 'contact' },
],
caseLabels: {
situation: 'Situation',
approach: 'Approach',
outcome: 'Outcome',
metrics: 'Metrics',
stack: 'Stack',
tags: 'Tags',
},
};
export const STACK_EN: StackPageCopy = {
routeId: 'stack',
title: 'Technology stack | Antonio Ledebuhr',
description:
'A technology overview in seven groups: programming, databases, DevOps, operating systems, infrastructure as code, hypervisors and tools.',
hero: {
headline: 'A technology overview, not a proof list',
proof:
'The grid is a familiarity list. It shows technologies from the work without claiming a public case for every entry.',
},
sections: [
{
id: 'reading',
headline: 'An overview, not a ranking',
body: [
'The grid groups technologies for orientation. It is not a ranking, and it does not claim that every entry appears in a public case.',
],
},
{
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.',
],
},
],
ctas: [
{ label: 'Projects', routeId: 'projects' },
{ label: 'About', routeId: 'about' },
],
groups: [
{ id: 'programming', title: 'Programming' },
{ id: 'db', title: 'Databases' },
{ id: 'devops', title: 'DevOps' },
{ id: 'os', title: 'Operating Systems' },
{ id: 'iac', title: 'Infrastructure as Code' },
{ id: 'hyperviser', title: 'Hypervisors' },
{ id: 'tools', title: 'Tools' },
],
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.',
};
export const ABOUT_EN: PageCopy = {
routeId: 'about',
title: 'About | Antonio Ledebuhr',
description:
'Antonio Ledebuhr, fullstack and DevOps engineer in Tangermünde. Freelance since 04/2023, German native, English at negotiation level.',
hero: {
headline: 'Antonio Ledebuhr, fullstack and DevOps engineer',
proof:
'Based in Tangermünde. Freelance since 04/2023. German native, English at negotiation level.',
},
sections: [
{
id: 'profile',
headline: 'Professional profile',
body: [
'About seven years of professional experience building and operating web applications. The work covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
'Home ground is Java (8/11/17/21, Maven, Spring Boot, Spring Data JPA, WebFlux, JUnit). Alongside that sit C# / .NET Core / .NET 8 (ASP.NET Core, Entity Framework, xUnit) and Angular (TypeScript, RxJS-era SPA work, Ngrx, SCSS).',
],
},
{
id: 'working',
headline: 'Way of working',
body: [
'Clean code, architecture that can be lived with, domain-driven design and test-driven development. Landing in a new domain is part of the work, not a side task.',
],
},
{
id: 'contact-and-language',
headline: 'Customer contact, teams and languages',
body: [
'Direct customer contact and team responsibility are part of the work so far, not a later add-on.',
'German is the native language. English is at negotiation level.',
],
},
{
id: 'accent',
headline: 'Roastery and torque',
body: [
'The roastery work remains the personal accent: brewing and torque rather than idle. Antonio Ledebuhr holds an economic stake in Rösterei Tangermünde.',
],
},
],
ctas: [
{ label: 'Projects', routeId: 'projects' },
{ label: 'For recruiters', routeId: 'pitch' },
{ label: 'Contact', routeId: 'contact' },
],
};
export const CONTACT_EN: ContactPageCopy = {
routeId: 'contact',
title: 'Contact | Antonio Ledebuhr',
description:
'A short project briefing in the browser. It only assembles a local email to info@antoniolede.de; nothing is sent to this server.',
hero: {
headline: 'A short briefing, then the local mail program',
proof:
'The fields stay in the browser. The button only opens a prefilled message to info@antoniolede.de.',
},
sections: [
{
id: 'how',
headline: 'What happens to the answers',
body: [
'Nothing is sent to this server. The filled fields become a mailto message that opens the local mail program.',
],
},
{
id: 'direct',
headline: 'Write directly',
body: [
'Anyone who prefers to skip the briefing can write to info@antoniolede.de. A calendar link is not published at the moment.',
],
},
],
ctas: [],
fields: [
{ id: 'name', control: 'text', label: 'Name', required: true },
{ id: 'email', control: 'email', label: 'Email', required: true },
{ id: 'company', control: 'text', label: 'Company', hint: 'optional', required: false },
{
id: 'projectType',
control: 'select',
label: 'Type of work',
hint: 'optional — leave blank or pick “Not sure yet” if the category is still open',
required: false,
options: [
{ value: 'unsure', label: 'Not sure yet' },
{ value: 'software', label: 'Software' },
{ value: 'hardware-network', label: 'Hardware and network' },
{ value: 'clusters', label: 'Clusters' },
{ value: 'ai', label: 'AI integration' },
{ value: 'other', label: 'Other' },
],
},
{
id: 'situation',
control: 'textarea',
label: 'Situation',
hint: 'What is in front of you, and where should the work start?',
required: true,
},
{ id: 'timeframe', control: 'text', label: 'Timeframe', hint: 'optional', required: false },
],
mailSubject: 'Project enquiry via antoniolede.de',
mailIntro: 'Short briefing from the website:',
mailSignature: 'Sent via the contact briefing on antoniolede.de.',
submitLabel: 'Open the mail program',
incompleteHint: 'Still needed:',
requiredMarkerLabel: 'Required field',
directEmailLabel: 'Write directly to info@antoniolede.de',
calendarLabel: 'Find a time',
noBackendNote:
'There is no form backend. Nothing is transmitted to this server. The button only opens the local mail program.',
};
export const IMPRINT_EN: LegalPageCopy = {
routeId: 'imprint',
title: 'Legal notice | Antonio Ledebuhr',
description:
'Legal notice for Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde. The text is unreviewed and must be checked by the site owner before publication.',
hero: {
headline: 'Legal notice',
proof:
'Name, address and email are taken from the curriculum vitae. Further required details are left blank on purpose.',
},
sections: [
{
id: 'scope',
headline: 'What is on this page',
body: [
'This text names the person, the address, the email address and responsibility for the content. It is not a finished legal notice.',
],
},
{
id: 'limit',
headline: 'What is missing',
body: [
'A VAT ID, register details, a supervisory authority, professional-liability insurance and a telephone number are not published here because they are not verified for this site.',
],
},
],
ctas: [{ label: 'Privacy', routeId: 'privacy' }],
reviewNotice:
'Warning: this legal text is unreviewed. The site owner must check it before any publication and complete the open items below.',
reviewTodos: [
'VAT identification number, if one exists',
'Register court and register number, if an entry exists',
'Supervisory authority, if one applies',
'Professional-liability insurance, if one exists',
'Telephone number, if it should be published',
'Hosting provider and its data processing',
],
legalSections: [
{
id: 'provider',
title: 'Provider',
body: [
'Antonio Ledebuhr',
'Kirchstr. 19',
'39590 Tangermünde',
'Germany',
'Email: info@antoniolede.de',
],
},
{
id: 'responsibility',
title: 'Responsible for the content',
body: [
'Responsible for the content of these pages is Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany.',
],
},
{
id: 'completeness',
title: 'No claim of completeness',
body: [
'This page does not claim legal completeness. Open items sit in the review list above and must be completed by the site owner before publication.',
],
},
],
};
export const PRIVACY_EN: LegalPageCopy = {
routeId: 'privacy',
title: 'Privacy | Antonio Ledebuhr',
description:
'Notes on processing for this static, server-rendered site without its own backend. The text is unreviewed and must be completed before publication.',
hero: {
headline: 'Privacy',
proof:
'The site has no backend of its own. The contact briefing stays in the browser and only assembles a mailto message.',
},
sections: [
{
id: 'scope',
headline: 'What this text covers',
body: [
'It describes the public site as far as that is verified here. It is not a finished privacy statement.',
],
},
{
id: 'limit',
headline: 'What the owner still has to add',
body: [
'The hosting provider, its processing and further legally required items sit in the review list and are left blank here on purpose.',
],
},
],
ctas: [{ label: 'Legal notice', routeId: 'imprint' }],
reviewNotice:
'Warning: this legal text is unreviewed. The site owner must check it before any publication and complete the open items below.',
reviewTodos: [
'Hosting provider and its commissioned processing',
'Server location and any sub-processors in use',
'A contact path for privacy requests, if email is not enough',
'Further legally required items once they are known',
],
legalSections: [
{
id: 'controller',
title: 'Controller',
body: [
'Antonio Ledebuhr, Kirchstr. 19, 39590 Tangermünde, Germany, email: info@antoniolede.de.',
],
},
{
id: 'site-nature',
title: 'Nature of this site',
body: [
'This site is a static, server-rendered site without its own backend. There is no user account and no server-side form intake on this domain.',
],
},
{
id: 'contact-briefing',
title: 'Contact briefing',
body: [
'The contact briefing runs entirely in the browser. It only assembles a mailto link. No form data reaches this site.',
],
},
{
id: 'email',
title: 'Email correspondence',
body: [
'When a message is sent to info@antoniolede.de, the correspondence is processed for the purpose of the enquiry. No other use is described here.',
],
},
{
id: 'rights',
title: 'Rights of data subjects',
body: [
'Data subjects have the general rights of access, rectification, erasure, restriction of processing, objection and complaint to a supervisory authority. How those rights are exercised depends on the specific matter.',
],
},
{
id: 'hosting',
title: 'Hosting',
body: [
'The hosting provider and its processing still have to be completed by the site owner. No provider is named here.',
],
},
],
};
export const NOT_FOUND_EN: PageCopy = {
routeId: 'notFound',
title: 'Page not found | Antonio Ledebuhr',
description:
'This address does not match a public page. Continue via home, services, projects or contact.',
hero: {
headline: 'This page is not on the public site',
proof:
'The address does not match a public route. The four links below lead back into the site.',
},
sections: [],
ctas: [
{ label: 'Back to home', routeId: 'home' },
{ label: 'Services', routeId: 'services' },
{ label: 'Projects', routeId: 'projects' },
{ label: 'Contact', routeId: 'contact' },
],
};

View File

@@ -0,0 +1,133 @@
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: about seven years of web application work, direct customer contact and team responsibility. Freelance since 04/2023.',
hero: {
headline: 'Fullstack and DevOps engineer based in Tangermünde',
proof:
'About seven years of professional experience building and operating web applications. Freelance since 04/2023.',
playfulLine: 'Full-stack, full throttle',
},
profile: [
'About seven years of professional experience building and operating web applications.',
'Fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and line management and team leadership.',
'Freelance since 04/2023, based in Tangermünde.',
'Java with Spring is the home stack; also C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, plus GitLab CI/CD and Azure DevOps.',
],
metrics: [
{
id: 'experience-years',
value: 'about 7 years',
label: 'professional experience with web applications',
},
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'with a larger feature scope and higher data quality',
caseId: 'innofocus',
},
{
id: 'myspa-team-lead',
value: '4 + 2',
label: 'led the backend team and the DevOps team through the MySpa launch',
caseId: 'myspa',
},
],
timelineHeading: 'Stations',
timeline: [
{
id: 'self-employed',
period: 'since 12/2021',
role: 'entrepreneurially self-employed',
body: 'Own responsibility for the brief, the technology and operations; own invoicing and client relationships.',
},
{
id: 'freelance',
period: 'since 04/2023',
role: 'freelance',
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements workshop through to production.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior fullstack / DevOps at HDI Specialty',
body: 'Fullstack and DevOps ownership in a regulated insurance setting.',
},
{
id: 'roesterei',
period: 'since 10/2023',
role: 'Full technical responsibility at Rösterei Tangermünde',
body: 'One contact for the companys technology as a whole; the economic stake stays disclosed.',
},
{
id: 'myspa',
period: '03/2024 09/2024',
role: 'Lead backend / DevOps at Aracom IT Services / MySpa',
body: 'Led backend and DevOps through go-live, taking requirements directly from the customer.',
},
{
id: 'innofocus',
period: 'since 09/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.',
},
],
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', 'myspa', 'hdi'],
sections: [
{
id: 'profile',
headline: 'Thirty seconds',
body: [
'Antonio Ledebuhr works as a fullstack and DevOps engineer from Tangermünde. The CV PDF carries the full record.',
],
},
{
id: 'featured-cases',
headline: 'Selected cases',
body: [
'Four cases cover eCommerce and ERP, shop IT, IoT and insurance. Each case is written 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}` },
],
};

View File

@@ -0,0 +1,440 @@
import { type ServicePageCopy, type ServicesOverviewPageCopy } from '../content.contracts';
export const SERVICES_OVERVIEW_EN: ServicesOverviewPageCopy = {
routeId: 'services',
title: 'Services | Antonio Ledebuhr',
description:
'Four service areas covering product software, infrastructure, clusters and AI integration — from the first workshop through to operations, with direct customer contact.',
hero: {
headline: 'Four service areas, one counterpart',
proof:
'Software, hardware and network, clusters, and AI integration. The path runs from the situation through diagnosis and implementation into operations.',
playfulLine: 'Systems that stay in gear',
},
sections: [
{
id: 'areas',
headline: 'The four areas',
body: [
'Software covers fullstack product work with Java and Spring Boot, C# and .NET, Angular, REST APIs, domain-driven design, test-driven development, and SQL / T-SQL data work.',
'Hardware and network covers network and server structures, employee devices, management SaaS such as Microsoft 365, and on-prem servers and virtualisation.',
'Clusters covers Docker and Kubernetes on-premise and on Azure AKS, migration off single hosts and PaaS onto clusters, GitLab CI/CD and Azure DevOps, and GitOps with Argo CD.',
'AI integration is the largest area: two delivered tools at Rösterei Tangermünde, and further building blocks that are scoped and validated per engagement.',
],
},
{
id: 'who',
headline: 'Who this is for',
body: [
'For companies that want one counterpart from the first requirements workshop through to production — without an extra layer between the business side and the technology.',
'For recruiters who want to read the public selection and the stack before opening the curriculum vitae.',
],
},
{
id: 'engagement',
headline: 'How an engagement runs',
body: [
'Situation first, then diagnosis, then implementation, then operations or handover. Each detail page follows that order and names a fitting public case.',
],
},
],
ctas: [
{ label: 'Software', routeId: 'servicesSoftware' },
{ label: 'Hardware and network', routeId: 'servicesHardwareNetwork' },
{ label: 'Clusters', routeId: 'servicesClusters' },
{ label: 'AI integration', routeId: 'servicesAi' },
{ label: 'Start a project enquiry', routeId: 'contact' },
],
systemsMap: {
heading: 'How the layers connect',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
},
};
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: [],
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',
offeringCaseBackedLabel: 'Backed by the public case {case}.',
};

Some files were not shown because too many files have changed in this diff Show More