60 Commits

Author SHA1 Message Date
4389f3dd3c Restore gist focus to the list and stop claiming the dialog is modal.
SVG nodes are not HTMLElements and sit in an aria-hidden figure, so close returns to the matching card. The gist stays non-modal: no aria-modal, no Tab trap, and destination or outside activation dismisses it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:43 +02:00
334b30145e Keep the terminal placeholder rule on the component stylesheet.
The global duplicate violated the component-owned selector rule; the surviving rule still pins subtle text at full opacity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:41 +02:00
0e1e48d876 Anchor About profile seniority to 2019 so the copy cannot age.
A counted duration would go stale; the approved wording keeps the same facts and lets the reader do the arithmetic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:39 +02:00
50851f4d26 Delete the unused ServicesOverviewPageCopy contract leftover.
The service-led migration already moved systems-map copy onto ServicesPageCopy, and no other dead ServicePage* types remain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 02:17:27 +02:00
0b74be69be Wrap the terminal stylesheet path so Prettier stays quiet.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:25 +02:00
7619205d91 Point Lighthouse at the consolidated Services pages instead of a deleted route.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:14 +02:00
8974b4c6fb Animate the terminal dock with transform only so entry never drops text contrast.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 01:08:14 +02:00
9bfb1dc71c Give the terminal placeholder a token color that meets contrast on the surface.
The browser default grey only showed up once the dock stayed open on 404.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:45:38 +02:00
bb9912daef Keep the default-open terminal inside the viewport and the map labels inside their shapes.
The dock prompt and placeholder now meet contrast on the raised surface, and service nodes use the same rect geometry as the cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:43:56 +02:00
a9ad7eb1a7 Rewrite the constraint specs and docs for the service-led information architecture.
Assertions now follow the eighteen prerenderable paths, the gist dialog, and the six-case evidence set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:41:24 +02:00
55d012cc10 Open the systems-map gist dialog and turn the dock into a default-open TTY.
Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:41:20 +02:00
2635d0c639 Collapse the four service routes into one anchored page.
Legacy detail URLs now 308 to locale-neutral fragments, and crawl plus JSON-LD follow the 18-path set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:41:16 +02:00
b95dbd8e1a Transcribe the approved service-led content contracts and bilingual copy.
The site now has one services page, six public cases, and no MySpa or calendar-scaffold strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:41:13 +02:00
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
219 changed files with 19481 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, Projects, About and Contact. Services is one anchored page with four sections. 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, one Service entry per services section on the services route, 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 owns the bilingual copy tree behind `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 bilingual copy tree 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('services', 'de'),
pagePath('services', '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)}#bannier`;
const label = `${width}px ${locale}`;
await page.goto(path, { waitUntil: 'networkidle' });
const header = page.locator('.site-header');
const heading = page.locator('#bannier 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');
}

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

@@ -0,0 +1,109 @@
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 primary nav stays a single row without a services submenu', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name === 'mobile', 'compact nav applies from md up');
await page.setViewportSize({ width: 1024, height: 900 });
await page.goto('/');
const services = page.locator(`.primary-nav > li > a[href="${pagePath('services', 'de')}"]`);
await expect(services).toBeVisible();
await expect(page.locator('.primary-nav .submenu')).toHaveCount(0);
await expect(page.locator('.primary-nav a')).toHaveCount(5);
await services.click();
await page.waitForURL('**/leistungen');
await expect(services).toHaveAttribute('aria-current', 'page');
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth + 1,
);
expect(overflow, 'horizontal overflow at 1024px').toBe(true);
});
test('terminal dock is open on load, does not lock the page, and restores on Escape', async ({
page,
}) => {
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await expect(panel).toBeVisible();
await expect(trigger).toBeHidden();
await expect(page.locator('#terminal-dock-input')).not.toBeFocused();
await expect(page.locator('.site')).not.toHaveAttribute('inert');
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
await expect(page.locator('.terminal-dock [role="dialog"]')).toHaveCount(0);
await page.locator('#terminal-dock-input').focus();
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('services', 'de'),
pagePath('contact', 'de'),
pagePath('imprint', 'en'),
'/missing-route',
];
const VIEWPORTS = [320, 390, 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);
}
});
});

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

@@ -0,0 +1,140 @@
import { expect, test, type Page } from '@playwright/test';
import { SITE_CONTENT_DATA } from '../src/app/core/content/site-content';
import { prerenderablePaths } from '../src/app/core/routing/route-paths';
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, services, 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, 'services', 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('every prerenderable path returns 200', async ({ request }) => {
const paths = prerenderablePaths();
expect(paths).toHaveLength(18);
for (const path of paths) {
const response = await request.get(path);
expect(response.status(), path).toBe(200);
}
});
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('.language-switch').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);
await page.locator(`.primary-nav a[href="${pagePath('services', 'de')}"]`).click();
await page.waitForURL('**/leistungen');
await expect(page.locator('link[rel="canonical"]')).toHaveCount(1);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
'https://antoniolede.de/leistungen',
);
await expect(page.locator('script[type="application/ld+json"]')).toHaveCount(1);
});
test('legacy service URLs return HTTP 308 with the fragment Location', async ({ request }) => {
const redirects = [
['/leistungen/software', '/leistungen#software-data'],
['/leistungen/software/', '/leistungen#software-data'],
['/leistungen/hardware-netzwerk', '/leistungen#infrastructure-network'],
['/leistungen/cluster', '/leistungen#platform-operations'],
['/leistungen/ai-integration', '/leistungen#ai-workflows'],
['/en/services/software', '/en/services#software-data'],
['/en/services/hardware-network', '/en/services#infrastructure-network'],
['/en/services/clusters', '/en/services#platform-operations'],
['/en/services/ai-integration', '/en/services#ai-workflows'],
['/en/services/ai-integration/', '/en/services#ai-workflows'],
] as const;
for (const [from, to] of redirects) {
const response = await request.get(from, { maxRedirects: 0 });
expect(response.status(), from).toBe(308);
expect(response.headers().location, from).toBe(to);
}
});
});

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

@@ -0,0 +1,155 @@
import { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
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 [320, 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('data-id') ?? 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 path = href.split('#')[0];
const response = await request.get(path);
expect(response.status(), href).toBe(200);
}
});
test('SVG node gist dialog restores focus after Escape and the close button', async ({
page,
}) => {
const copy = SIGNATURE_COPY.de.systemsMap;
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 1440, height: 900 });
const svgNode = page.locator('.systems-map-figure .systems-map-node').first();
const listNode = page.locator('.systems-map-cards a').first();
await expect(page.locator('.systems-map-figure')).toBeVisible();
await svgNode.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).not.toHaveAttribute('aria-modal');
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(listNode).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
await svgNode.click();
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: copy.closeLabel }).click();
await expect(dialog).toHaveCount(0);
await expect(listNode).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
});
test('plain activation opens the gist dialog without navigating', async ({ page }) => {
const copy = SIGNATURE_COPY.de.systemsMap;
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 390, height: 844 });
const first = page.locator('.systems-map-cards a').first();
const href = await first.getAttribute('href');
expect(href).toContain('#infrastructure-network');
await first.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).not.toHaveAttribute('aria-modal');
await expect(page).toHaveURL(new RegExp(`${pagePath('services', 'de')}$`));
await expect(dialog.getByRole('link')).toHaveAttribute('href', href ?? '');
await expect(dialog.getByRole('button', { name: copy.closeLabel })).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox, 'dialog readable at 390px').toBeTruthy();
expect(dialogBox!.width).toBeGreaterThan(200);
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(first).toBeFocused();
});
test('destination and outside activation dismiss the gist and restore focus', async ({
page,
}) => {
await page.goto(pagePath('services', 'de'));
await page.setViewportSize({ width: 390, height: 844 });
const first = page.locator('.systems-map-cards a').first();
await first.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('link').click();
await expect(dialog).toHaveCount(0);
await expect(page).toHaveURL(/#infrastructure-network/);
await expect(first).toBeFocused();
await first.click();
await expect(dialog).toBeVisible();
await page.locator('.systems-map-heading').click();
await expect(dialog).toHaveCount(0);
await expect(first).toBeFocused();
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(false);
});
test('a modified click follows the node href', async ({ page }) => {
await page.goto(pagePath('services', 'de'));
const first = page.locator('.systems-map-cards a').first();
await first.click({ modifiers: ['Control'] });
await expect(page.getByRole('dialog')).toHaveCount(0);
});
});

158
e2e/terminal.e2e.ts Normal file
View File

@@ -0,0 +1,158 @@
import { expect, test } from '@playwright/test';
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
import { expectNoOverflow } from './helpers';
const VIEWPORTS = [320, 390, 768, 1024, 1440] as const;
test.describe('terminal dock', () => {
test('defaults open, keeps one session scrollport 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 session = page.locator('.terminal-dock-session');
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await expect(panel).toBeVisible();
await expect(trigger).toBeHidden();
await expect(input).not.toBeFocused();
await expect(session).toBeVisible();
await expect(session.locator('form')).toHaveCount(1);
await expect(collapse).toHaveAttribute('title', copy.collapseTooltip);
await expect(maximize).toHaveAttribute('aria-label', copy.maximizeLabel);
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 expect(maximize).toHaveAttribute('aria-label', copy.restoreLabel);
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(session).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(session).toContainText(copy.historyIntro);
await expect(session).toContainText('navigate pitch');
await input.fill('clear');
await input.press('Enter');
await expect(session).toContainText(copy.clearedMessage);
await expect(session.locator('p')).toHaveCount(1);
await input.fill('navigate p');
await input.press('Tab');
await expect(session).toContainText('pitch');
await expect(session).toContainText('projects');
await input.fill('navigate nowhere');
await input.press('Enter');
await expect(session).toContainText(copy.validTargetsLabel);
await expect(session).toContainText('pitch');
await input.fill('nav');
await input.press('Tab');
await expect(input).toHaveValue('navigate');
await input.fill('navigate services sof');
await input.press('Tab');
await expect(input).toHaveValue('navigate services software');
});
test('collapse control hides the panel 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 expect(panel).toBeVisible();
await expect(trigger).toBeHidden();
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.click();
await expect(panel).toHaveCount(0);
await expect(trigger).toBeVisible();
await expect(trigger).toBeFocused();
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
test('keeps the prompt in the session scrollport after overflow', async ({ page }) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const input = page.locator('#terminal-dock-input');
const session = page.locator('.terminal-dock-session');
await expect(session).toBeVisible();
for (let index = 0; index < 8; index += 1) {
await input.fill('help');
await input.press('Enter');
}
await expect(session.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
await expect
.poll(async () =>
session.evaluate((element) => {
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
}),
)
.toBe(true);
const form = session.locator('form');
const sessionBox = await session.boundingBox();
const formBox = await form.boundingBox();
expect(sessionBox && formBox).toBeTruthy();
expect(formBox!.y).toBeGreaterThanOrEqual(sessionBox!.y - 1);
expect(formBox!.y + formBox!.height).toBeLessThanOrEqual(
sessionBox!.y + sessionBox!.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 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();
});
test('stays usable and overflow-free at the required viewports', async ({ page }) => {
await page.goto('/');
for (const width of VIEWPORTS) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('.terminal-dock-panel')).toBeVisible();
await expectNoOverflow(page, '/', width);
}
});
});

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,
);

34
lighthouserc.json Normal file
View File

@@ -0,0 +1,34 @@
{
"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",
"http://127.0.0.1:4000/en/services",
"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

49
public/llms.txt Normal file
View File

@@ -0,0 +1,49 @@
# Antonio Ledebuhr
Fullstack and DevOps engineer in Tangermünde. The public site documents six public cases, one services page with four anchored areas, and a briefing form that only opens a local mail client. German is the default language; English is the full second version.
## Profile
- https://antoniolede.de/
- https://antoniolede.de/en
- https://antoniolede.de/pitch — Recruiter-Profil / recruiter profile
- https://antoniolede.de/en/pitch — Recruiter profile / Recruiter-Profil
- https://antoniolede.de/ueber-mich
- https://antoniolede.de/en/about
## Services
- https://antoniolede.de/leistungen
- https://antoniolede.de/en/services
## 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#bannier
- https://antoniolede.de/en/projects#bannier
- https://antoniolede.de/projekte#elbwaerme
- https://antoniolede.de/en/projects#elbwaerme
- https://antoniolede.de/projekte#hdi
- https://antoniolede.de/en/projects#hdi
- https://antoniolede.de/projekte#devdays
- https://antoniolede.de/en/projects#devdays
## Contact, stack and legal
- 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

111
public/sitemap.xml Normal file
View File

@@ -0,0 +1,111 @@
<?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/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/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,73 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> <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: item.routeId === 'home' }"
ariaCurrentWhenActive="page"
>{{ item.label }}</a
>
</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>
</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(18);
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,58 @@
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),
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,187 @@
@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: center;
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);
}
.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,
.site-toolbar {
display: none;
}
.site-header {
padding-block: var(--space-2);
}
.site-header-inner {
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-2);
}
.site-nav,
.nav-collapsed .site-nav,
.site-actions,
.nav-collapsed .site-actions {
display: flex;
grid-column: auto;
}
.site-nav {
justify-content: center;
}
.primary-nav {
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-3);
}
.primary-nav > li > a {
white-space: nowrap;
}
}

View File

@@ -1,23 +1,168 @@
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();
const panel = compiled.querySelector('.terminal-dock-panel');
expect(panel).toBeTruthy();
expect((trigger as HTMLButtonElement).hasAttribute('hidden')).toBe(true);
expect(site?.contains(panel)).toBe(false);
expect(site?.hasAttribute('inert')).toBe(false);
});
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {
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,46 @@
.card {
border-radius: var(--radius-lg);
padding: var(--space-6);
height: 100%;
display: flex;
flex-direction: column;
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 .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,154 @@
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 site.services.serviceSections.flatMap((section) => section.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 six 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',
'bannier',
'elbwaerme',
'hdi',
'devdays',
]);
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 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 sections = site.services.serviceSections;
const ai = sections.find((section) => section.id === 'ai-workflows');
expect(ai, `${locale} ai-workflows section`).toBeTruthy();
const aiDelivered = ai!.offerings.filter((offering) => offering.status === 'delivered');
expect(aiDelivered).toHaveLength(2);
expect(aiDelivered.every((offering) => offering.referenceCaseId === 'roesterei')).toBe(true);
expect(
ai!.offerings
.filter((offering) => offering.status !== 'delivered')
.every((offering) => offering.status === 'offer'),
).toBe(true);
const everyDelivered = allOfferings(site).filter(
(offering) => offering.status === 'delivered',
);
expect(
everyDelivered.every(
(offering) =>
!!offering.referenceCaseId && CASE_STUDY_IDS.includes(offering.referenceCaseId),
),
`${locale}: every delivered offering names a public case`,
).toBe(true);
const offered = allOfferings(site).filter((offering) => offering.status === 'offer');
expect(
offered.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,117 @@
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.services);
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);
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', 'ara' + 'com', 'my' + 'spa'].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,144 @@
import { APP_LOCALES } from '../i18n/locale';
import { CASE_STUDY_IDS, SERVICE_SECTION_IDS, type CaseStudyCopy } from './content.contracts';
import { SITE_CONTENT_DATA } from './site-content';
const DEVICE_IN_HEADING = /arbeitsplatz|workstation|gerät(?:e|esupport)?|device support|endgerät/i;
function caseBlob(study: CaseStudyCopy): string {
return [
study.period,
study.domain,
study.headline,
study.summary,
study.responsibility,
study.situation,
...study.approach,
...study.outcome,
study.teaser,
study.externalUrl ?? '',
study.externalLabel ?? '',
].join('\n');
}
describe('service-led content contracts', () => {
it('keeps service sections infrastructure-first in the fixed order', () => {
expect(SERVICE_SECTION_IDS).toEqual([
'infrastructure-network',
'platform-operations',
'software-data',
'ai-workflows',
]);
for (const locale of APP_LOCALES) {
const ids = SITE_CONTENT_DATA[locale].services.serviceSections.map((section) => section.id);
expect(ids, locale).toEqual([...SERVICE_SECTION_IDS]);
expect(SITE_CONTENT_DATA[locale].home.serviceAreas.map((area) => area.id)).toEqual([
...SERVICE_SECTION_IDS,
]);
}
});
it('keeps Bannier and Elbwärme dates, scopes, externals and the support-versus-development line', () => {
const de = SITE_CONTENT_DATA.de.cases;
const en = SITE_CONTENT_DATA.en.cases;
expect(de.bannier.period).toBe('seit etwa 2018, laufend');
expect(en.bannier.period).toBe('since roughly 2018, ongoing');
expect(de.elbwaerme.period).toBe('seit 11/2025, laufend');
expect(en.elbwaerme.period).toBe('since November 2025, ongoing');
expect(de.bannier.externalUrl).toBe('https://fahrschule-stendal.de');
expect(en.bannier.externalUrl).toBe('https://fahrschule-stendal.de');
expect(de.elbwaerme.externalUrl).toBe('https://elbwaerme.de');
expect(en.elbwaerme.externalUrl).toBe('https://elbwaerme.de');
expect(de.bannier.externalLabel).toBe('fahrschule-stendal.de öffnen');
expect(en.bannier.externalLabel).toBe('Visit fahrschule-stendal.de');
expect(de.elbwaerme.externalLabel).toBe('elbwaerme.de öffnen');
expect(en.elbwaerme.externalLabel).toBe('Visit elbwaerme.de');
expect(caseBlob(de.bannier)).toMatch(/nicht entwickelt/);
expect(caseBlob(de.bannier)).toMatch(/betreue und betreibe/);
expect(caseBlob(en.bannier)).toMatch(/did not develop/);
expect(caseBlob(en.bannier)).toMatch(/support and operate/);
for (const caseId of CASE_STUDY_IDS.filter((id) => id !== 'bannier' && id !== 'elbwaerme')) {
expect(Object.hasOwn(de[caseId], 'externalUrl')).toBe(false);
expect(Object.hasOwn(en[caseId], 'externalUrl')).toBe(false);
expect(Object.hasOwn(de[caseId], 'externalLabel')).toBe(false);
expect(Object.hasOwn(en[caseId], 'externalLabel')).toBe(false);
}
});
it('states the personal contact, two-person team, organisational support and specialist network', () => {
const de = SITE_CONTENT_DATA.de;
const en = SITE_CONTENT_DATA.en;
expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg',
);
expect(de.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'ausgesuchtes Netzwerk',
);
expect(de.services.sections[1].body?.join('\n')).toContain('technischer Ansprechpartner');
expect(de.services.sections[1].body?.join('\n')).toContain('Kaufmännisches und Organisation');
expect(en.services.sections[1].body?.join('\n')).toContain('technical point of contact');
expect(en.services.sections[1].body?.join('\n')).toContain('two people');
expect(en.home.sections.find((section) => section.id === 'team')?.body?.join('\n')).toContain(
'two people',
);
expect(
en.pages.about.sections
.find((section) => section.id === 'contact-and-team')
?.body?.join('\n'),
).toMatch(/curated/);
expect(en.pitch.profile.join('\n')).toContain('curated network of specialists');
expect(de.pitch.profile.join('\n')).toContain('ausgesuchtes Fachnetzwerk');
});
it('keeps About profile seniority anchored to 2019 instead of a computed duration', () => {
const duration = /\b(sieben|seven|\d+)\s+(Jahre|Jahren|years)\b/i;
for (const locale of APP_LOCALES) {
const about = SITE_CONTENT_DATA[locale].pages.about;
const profile = about.sections.find((section) => section.id === 'profile');
const blob = [
about.title,
about.description,
about.hero.headline,
about.hero.proof ?? '',
about.hero.playfulLine ?? '',
...(about.hero.body ?? []),
...about.sections.flatMap((section) => [section.headline, ...(section.body ?? [])]),
...about.ctas.map((cta) => cta.label),
].join('\n');
expect(profile?.body, `${locale} profile body`).toHaveLength(2);
expect(blob, locale).not.toMatch(duration);
}
expect(SITE_CONTENT_DATA.de.pages.about.sections[0].body?.[0]).toBe(
'Berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen seit 2019. Die Arbeit umfasst Fullstack-Entwicklung, DevOps, direkten Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
);
expect(SITE_CONTENT_DATA.en.pages.about.sections[0].body?.[0]).toBe(
'Professional work on building and operating web applications goes back to 2019. It covers fullstack development, DevOps, direct customer contact from the first requirements workshop through to production, and personnel and team responsibility.',
);
});
it('keeps device and workstation wording out of headings, nav labels and offering titles', () => {
for (const locale of APP_LOCALES) {
const site = SITE_CONTENT_DATA[locale];
const headings = [
...site.home.serviceAreas.map((area) => area.title),
...site.services.serviceSections.map((section) => section.title),
...site.services.serviceSections.flatMap((section) =>
section.offerings.map((offering) => offering.title),
),
...site.services.sections.map((section) => section.headline),
];
for (const heading of headings) {
expect(DEVICE_IN_HEADING.test(heading), `${locale}: ${heading}`).toBe(false);
}
}
});
});

View File

@@ -0,0 +1,254 @@
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' | 'bannier' | 'elbwaerme' | 'hdi' | 'devdays';
export const CASE_STUDY_IDS: readonly CaseStudyId[] = [
'innofocus',
'roesterei',
'bannier',
'elbwaerme',
'hdi',
'devdays',
];
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;
readonly teaser: string;
readonly externalUrl?: string;
readonly externalLabel?: 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 ServiceEvidenceCopy {
readonly id: string;
readonly heading: string;
readonly note: string;
readonly caseIds: readonly CaseStudyId[];
}
export type ServiceSectionId =
'infrastructure-network' | 'platform-operations' | 'software-data' | 'ai-workflows';
export const SERVICE_SECTION_IDS: readonly ServiceSectionId[] = [
'infrastructure-network',
'platform-operations',
'software-data',
'ai-workflows',
];
export interface ServiceSectionCopy {
readonly id: ServiceSectionId;
readonly title: string;
readonly lead: string;
readonly situation: SectionCopy;
readonly diagnosis: SectionCopy;
readonly implementation: SectionCopy;
readonly operation: SectionCopy;
readonly offerings: readonly OfferingCopy[];
readonly evidence: ServiceEvidenceCopy;
readonly inquiry: SectionCopy;
}
export interface ServicesPageCopy extends PageCopy {
readonly serviceSections: readonly ServiceSectionCopy[];
readonly sectionsHeading: string;
readonly deliveredOfferingsHeading: string;
readonly offerOfferingsHeading: string;
readonly offeringCaseBackedLabel: string;
readonly evidenceHeading: string;
readonly inquiryHeading: string;
readonly systemsMap: { readonly heading: string; readonly intro: string };
}
export interface HomeServiceAreaCopy {
readonly id: ServiceSectionId;
readonly title: string;
readonly body: string;
readonly routeId: RouteId;
readonly fragment: ServiceSectionId;
}
export interface HomePageCopy extends PageCopy {
readonly serviceAreas: readonly HomeServiceAreaCopy[];
readonly serviceAreasHeading: string;
readonly process: readonly ProcessStepCopy[];
readonly processHeading: string;
readonly proofCaseId: CaseStudyId;
readonly proofHeading: string;
readonly proofNote: string;
readonly featuredCaseIds: readonly CaseStudyId[];
}
export interface PitchTimelineEntryCopy {
readonly id: string;
readonly period: string;
readonly role: string;
readonly body: string;
}
export interface PitchPageCopy extends PageCopy {
readonly profile: readonly string[];
readonly metrics: readonly MetricCopy[];
readonly timeline: readonly PitchTimelineEntryCopy[];
readonly coreStack: readonly StackGroupCopy[];
readonly featuredCaseIds: readonly CaseStudyId[];
readonly timelineHeading: string;
readonly stackHeading: 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 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 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 services: ServicesPageCopy;
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,66 @@
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 ServicesPageCopy,
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);
}
services(): Signal<ServicesPageCopy> {
return computed(() => this.content[this.localeService.locale()].services);
}
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,263 @@
import { type CaseStudyCopy, type CaseStudyId } from '../content.contracts';
export const CASES_DE: Record<CaseStudyId, CaseStudyCopy> = {
innofocus: {
id: 'innofocus',
client: 'Innofocus / reifen.com',
role: 'Senior Backend- und Datenbankingenieur',
period: 'seit 09/2025, laufend',
domain: 'eCommerce / ERP',
headline: 'Normalisierte ERP-Datenmigration im Microsoft SQL Server',
summary:
'Das bestehende ERP wird durch ein neues, normalisiertes System ersetzt. Die Migration sämtlicher Altdaten läuft direkt im Microsoft SQL Server und liegt bei mir.',
responsibility:
'Alleinverantwortung für die Migration: das Migrationsteam besteht aus mir. Als einziger Freiberufler stehe ich im direkten Austausch mit dem Endkunden.',
situation:
'Das bestehende ERP wird durch ein neues, normalisiertes System abgelöst; sämtliche Altdaten wandern direkt im Microsoft SQL Server mit. Die Altstruktur ist nicht normalisiert, die Zielstruktur ist es — die Migration besteht deshalb vor allem aus Datenbereinigung, Normalisierung und Dokumentation für eine prüfende Stelle. Zuvor lag die Migration bei einem anderen Dienstleister und wurde von mir übernommen.',
approach: [
'Umfangreiche Stored Procedures in T-SQL für Transformation, Normalisierung und Bereinigung der Altbestände.',
'Punktuelle Mitarbeit an der Anwendung selbst mit C#, .NET und Angular.',
'Authentifizierung und Autorisierung über Keycloak mit OAuth 2 und OIDC.',
'Betrieb der Anwendung auf Kubernetes, GitOps-Deployments über Argo CD.',
'Versionierung mit Git, Pipelines und Projektsteuerung über Azure DevOps.',
],
outcome: [
'Ein Migrationslauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
'Die Zielstruktur ist normalisiert und für die prüfende Stelle dokumentiert.',
],
metrics: [
{
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', 'Kubernetes'],
linkLabel: 'Fall lesen: Innofocus / reifen.com',
teaser:
'ERP-Ablösung mit vollständiger Datenmigration im SQL Server: ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden.',
},
roesterei: {
id: 'roesterei',
client: 'Rösterei Tangermünde',
role: 'Gesamtverantwortung für die Technik',
period: 'seit 10/2023, laufend',
domain: 'eCommerce und interne IT',
headline: 'Vom Netz bis zum Shop: die gesamte Technik in einer Hand',
summary:
'Aus dem Auftrag für einen neuen Onlineshop wurde die Verantwortung für die gesamte Technik des Unternehmens — Netz, Server, Mitarbeitergeräte, Microsoft 365, Shop und Marketing.',
responsibility:
'Die gesamte Technik liegt bei mir: Netzwerk- und Serverstrukturen, Mitarbeitergeräte, Management-SaaS wie Microsoft 365, der neue Onlineshop und das neue Marketing.',
situation:
'Zuerst sollte nur ein neuer Onlineshop entstehen. Durch sehr dynamische Umstände wuchs der Gegenstand weit darüber hinaus. Anforderungen kommen direkt von nicht-technischen Beteiligten; die einheitliche Markenführung entsteht im Austausch mit einer Designerin.',
approach: [
'Zeitgemäße und kosteneffektive Infrastruktur statt unnötiger Komplexität: Netz- und Serverstrukturen, Virtualisierung mit Proxmox, Container und Kubernetes, Microsoft 365.',
'Shop-Version 1: Shopify mit eigenem Theme und eigenen Komponenten in Liquid.',
'Shop-Version 2: Liquid und Hydrogen unter Einbindung eigener Dienste.',
'Interne Werkzeuge für geringere Kosten und mehr Autonomie, darunter ein Generator für Vektorgrafiken in Werbematerial.',
'Zwei KI-Werkzeuge im täglichen Einsatz: eines kategorisiert Kundenbewertungen, eines beschleunigt die Verwaltung des neuen Shops — vergleichbar mit Shopify Sidekick und in dieselbe interne Werkzeugkette eingebunden.',
],
outcome: [
'Die Technik des Unternehmens liegt in einer Hand, vom Netz bis zum Shop.',
'Zwei KI-Werkzeuge laufen im internen Betrieb: 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', 'Infrastruktur', 'Shopify', 'KI-Werkzeuge'],
linkLabel: 'Fall lesen: Rösterei Tangermünde',
transparencyNote: 'Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
teaser:
'Gesamte Technik seit 10/2023: Netz, Server, Microsoft 365, Onlineshop und zwei interne KI-Werkzeuge.',
},
bannier: {
id: 'bannier',
client: 'Fahrschule Bannier',
role: 'IT-Betreuung und Betrieb',
period: 'seit etwa 2018, laufend',
domain: 'Fahrschule / interne IT',
headline: 'Die gesamte IT einer Fahrschule, betreut seit etwa 2018',
summary:
'Ich betreue die gesamte IT der Fahrschule: die Computer, den laufenden Support, den Betrieb der eingesetzten Fachsoftware und die Website. Die Fachsoftware stammt von einem anderen Anbieter — entwickelt habe ich sie nicht.',
responsibility:
'Verantwortlich für die gesamte IT: die Computer, der laufende Support, die Website und der Betrieb der vorhandenen Fahrschulsoftware. Wichtig zur Einordnung: Diese Fachsoftware ist ein Fremdprodukt. Ich betreue und betreibe sie — ich habe sie nicht entwickelt.',
situation:
'Die Fahrschule arbeitet mit einer spezialisierten Fahrschulsoftware eines externen Anbieters. Meine Aufgabe ist der Betrieb und die Betreuung dieser Software im Alltag — Einrichtung, Aktualisierung, Anbindung, Störungen —, ausdrücklich nicht ihre Entwicklung. Dazu kommen die Computer, das Netz, der laufende Support und die Website unter fahrschule-stendal.de.',
approach: [
'Betrieb und Betreuung der vorhandenen Fahrschulsoftware: Einrichtung, Aktualisierungen, Datensicherung und Störungsbehebung. Die Entwicklung dieser Software liegt beim Hersteller, nicht bei mir.',
'Betreuung der Computer und des Netzes, einschließlich Austausch und Nachrüstung.',
'Laufender Support als fester Ansprechpartner, ohne Ticketkette dazwischen.',
'Betrieb und Pflege der öffentlichen Website unter fahrschule-stendal.de.',
],
outcome: [
'Die IT läuft seit etwa 2018 durchgehend unter derselben Betreuung.',
'Ein Ansprechpartner für Rechner, Netz, Fachsoftware und Website. Die Fachsoftware bleibt ein Fremdprodukt; ich betreibe sie, entwickelt wird sie beim Hersteller.',
],
metrics: [],
stack: [
'Arbeitsplatzrechner',
'Netzwerk',
'Datensicherung',
'Fachsoftware eines Drittanbieters (betreut und betrieben, nicht entwickelt)',
'Website-Betrieb',
],
tags: ['interne IT', 'Support', 'Betrieb', 'Website'],
linkLabel: 'Fall lesen: Fahrschule Bannier',
teaser:
'Gesamte IT seit etwa 2018: Rechner, Netz, Support, Website. Die Fachsoftware betreibe und betreue ich — entwickelt habe ich sie nicht.',
externalUrl: 'https://fahrschule-stendal.de',
externalLabel: 'fahrschule-stendal.de öffnen',
},
elbwaerme: {
id: 'elbwaerme',
client: 'Elbwärme',
role: 'Infrastruktur und IT-Betreuung',
period: 'seit 11/2025, laufend',
domain: 'Infrastruktur und interne IT',
headline: 'Infrastruktur, Microsoft 365 und Website unter einer Verantwortung',
summary:
'Seit 11/2025 verantworte ich bei Elbwärme die gesamte Infrastruktur, Microsoft 365 und die Website. Ein junges Mandat, das mitwächst.',
responsibility:
'Verantwortlich für die gesamte Infrastruktur, für Microsoft 365 und für die Website unter elbwaerme.de.',
situation:
'Elbwärme braucht eine technische Grundlage, die zum Betrieb passt und mitwächst: eine zusammenhängende Infrastruktur, die Dienste für die tägliche Arbeit und einen öffentlichen Auftritt. Die Verantwortung dafür liegt seit 11/2025 bei mir; das Mandat ist damit bewusst als jung beschrieben.',
approach: [
'Die Infrastruktur als zusammenhängende Schicht verantworten statt als Sammlung einzelner Geräte.',
'Einrichtung und Betrieb von Microsoft 365 mit nachvollziehbaren Konten und Rechten.',
'Betrieb und Pflege der Website unter elbwaerme.de.',
'Ein fester Ansprechpartner für technische Fragen im Alltag.',
],
outcome: [
'Infrastruktur, Microsoft 365 und Website liegen in einer Hand.',
'Die Zusammenarbeit läuft seit 11/2025 weiter. Aussagen über lange Zeiträume gibt es hier bewusst noch nicht.',
],
metrics: [],
stack: ['Microsoft 365', 'Netzwerk', 'Serverbetrieb', 'Website-Betrieb'],
tags: ['Infrastruktur', 'Microsoft 365', 'Betreuung', 'Website'],
linkLabel: 'Fall lesen: Elbwärme',
teaser:
'Seit 11/2025: gesamte Infrastruktur, Microsoft 365 und Website in einer Hand. Ein junges, laufendes Mandat.',
externalUrl: 'https://elbwaerme.de',
externalLabel: 'elbwaerme.de öffnen',
},
hdi: {
id: 'hdi',
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 gezielte Risiken zu ermitteln, sie stochastisch auswertet, Policen zentral sammelt und Auswertungen aller Policen für die BaFin erzeugt.',
responsibility:
'Senior-Fullstack- und DevOps-Arbeit an der Exposure-Management-Software, einschließlich der Migration aller Umgebungen auf ein Kubernetes-Cluster auf Azure AKS.',
situation:
'HDI Specialty braucht Software für das Exposure Management: Simulationsfolgen sollen automatisiert bestimmte Risiken ermitteln, stochastisch ausgewertet werden, Policen zentral sammeln und Auswertungen aller Policen für die BaFin liefern. Parallel dazu weichen Entwicklungs-, QS- und Produktivumgebung auf Azure App Services voneinander ab.',
approach: [
'Single-Page-Anwendung mit Angular, TypeScript, HTML und SCSS/CSS, Ngrx Store für den Anwendungsstatus und Ngx Pipes für die Datenaufbereitung.',
'Backend mit C# und .NET, REST-API und Entity Framework für den Datenbankzugriff.',
'Testgetriebene Entwicklung mit Unit- und Integrationstests in xUnit.',
'Administration von Azure SQL Server, Anmeldung und Berechtigungen über Okta mit OAuth 2.',
'CI/CD-Pipelines und Projektsteuerung mit Azure DevOps, Arbeit nach Scrum.',
],
outcome: [
'Entwicklungs-, QS- und Produktivumgebung wurden von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS migriert.',
'Die Auswertungen für die BaFin entstehen aus dem zentral gesammelten Policenbestand.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'Ngrx Store',
'Ngx Pipes',
'C#',
'.NET',
'Entity Framework',
'xUnit',
'Azure SQL Server',
'Okta',
'OAuth 2',
'Azure AKS',
'Azure DevOps',
],
tags: ['Versicherung', 'Fullstack', 'Kubernetes', 'Azure', 'Regulierung'],
linkLabel: 'Fall lesen: HDI Specialty',
teaser:
'Exposure Management mit Angular und .NET; Dev-, QS- und Produktivumgebung zogen von Azure App Services nach Azure AKS um.',
},
devdays: {
id: 'devdays',
client: 'Magdeburger Dev Days',
role: 'Fullstack-Entwickler',
period: '08/2020 03/2022',
domain: 'Veranstaltung und Web',
headline: 'Besucherseite, Planungswerkzeuge und eine Streamingplattform auf Kubernetes',
summary:
'Fullstack-Arbeit am jährlichen Entwicklerevent: die öffentliche Besucherseite und die internen Planungswerkzeuge. Für 2021 kam eine Streamingplattform auf Basis von Kubernetes dazu, damit die Veranstaltung online stattfinden konnte.',
responsibility:
'Fullstack-Entwicklung an Besucherseite und internen Planungswerkzeugen. Der Aufbau der Streamingplattform für die Online-Ausgabe 2021 lag bei mir.',
situation:
'Die Magdeburger Dev Days sind ein jährliches Entwicklerevent. Das Projekt umfasst die Homepage für Besucher und die internen Planungswerkzeuge. 2021 konnte die Veranstaltung nicht vor Ort stattfinden und brauchte eine eigene Plattform zum Streamen.',
approach: [
'Frontend mit Angular, TypeScript, HTML und SCSS/CSS, Ngrx Store für den Anwendungsstatus, Ngx Pipes für die Datenaufbereitung und Angular Material für die Komponenten.',
'Node.js zur Unterstützung der Entwicklungsumgebung.',
'Backend mit C# und .NET Core, Datenhaltung in Microsoft SQL Server 2019 mit SQL und T-SQL.',
'Streamingplattform für die Ausgabe 2021 auf Basis von Kubernetes.',
'CI/CD-Pipelines für automatisierte Tests und Deployments, Arbeit nach Scrum.',
],
outcome: [
'Die Ausgabe 2021 lief online über die selbst gebaute Streamingplattform.',
'Besucherseite und interne Planungswerkzeuge wurden gemeinsam entwickelt und gepflegt.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'HTML',
'SCSS',
'Ngrx Store',
'Ngx Pipes',
'Angular Material',
'Node.js',
'C#',
'.NET Core',
'SQL',
'T-SQL',
'Microsoft SQL Server 2019',
'Kubernetes',
'GitLab CI/CD',
],
tags: ['Veranstaltung', 'Fullstack', 'Kubernetes', 'Streaming'],
linkLabel: 'Fall lesen: Magdeburger Dev Days',
teaser:
'Besucherseite und interne Planungswerkzeuge; für 2021 eine Streamingplattform auf Kubernetes, damit das Event online lief.',
},
};

View File

@@ -0,0 +1,109 @@
import { type HomePageCopy } from '../content.contracts';
export const HOME_DE: HomePageCopy = {
routeId: 'home',
title: 'Startseite | Antonio Ledebuhr',
description:
'Server, Netze und Betrieb aus einer Hand: Du beschreibst, was Dein Unternehmen erreichen soll, ich übernehme den Weg bis zum Betrieb oder zur Übergabe.',
hero: {
headline:
'Du beschreibst, was Dein Unternehmen erreichen soll. Ich kümmere mich um den technischen Weg.',
proof:
'Bei der Fahrschule Bannier liegt die gesamte IT seit etwa 2018 bei mir. Bei Elbwärme verantworte ich seit 11/2025 Infrastruktur, Microsoft 365 und Website.',
playfulLine: 'Kaffee, Kabel, Code',
body: [
'Server, Netze und der Betrieb darauf sind der Ausgangspunkt. Darüber liegen die Plattformdienste, die Produktsoftware mit ihrer Datenarbeit und, wo es sich rechnet, ein KI-Werkzeug im internen Ablauf.',
'Du schilderst das Ziel, den Engpass oder den wiederkehrenden Ablauf in Deinen Worten. Ich ordne ein, sage, was dazugehört und was nicht, und führe von der Diagnose über die Umsetzung bis zum Betrieb oder zur Übergabe.',
],
},
serviceAreasHeading: 'Wobei ich helfen kann',
serviceAreas: [
{
id: 'infrastructure-network',
title: 'Server, Netz und Virtualisierung',
body: 'Die Schicht darunter: Netzstrukturen, Server und Virtualisierung vor Ort, geplant auf Kosten und Wartbarkeit statt auf Katalogseiten.',
routeId: 'services',
fragment: 'infrastructure-network',
},
{
id: 'platform-operations',
title: 'Plattform und Betrieb',
body: 'Microsoft 365, Container und Kubernetes, Pipelines und GitOps — und eine klare Antwort darauf, wer das danach betreibt.',
routeId: 'services',
fragment: 'platform-operations',
},
{
id: 'software-data',
title: 'Software und Daten',
body: 'Produktsoftware von der Fachlichkeit bis zur Oberfläche, dazu SQL-Datenarbeit und Migrationen, die prüfbar bleiben.',
routeId: 'services',
fragment: 'software-data',
},
{
id: 'ai-workflows',
title: 'KI-Abläufe',
body: 'Zwei umgesetzte Werkzeuge bei der Rösterei Tangermünde. Weitere Bausteine werden je Auftrag geschnitten und geprüft.',
routeId: 'services',
fragment: 'ai-workflows',
},
],
processHeading: 'So läuft die Zusammenarbeit',
process: [
{
id: 'describe',
title: 'Lage in Deinen Worten',
body: 'Du beschreibst das Ziel, den Engpass oder den wiederkehrenden Ablauf. Fachsprache ist nicht nötig.',
},
{
id: 'diagnose',
title: 'Diagnose',
body: 'Ich ordne die Lage einem der vier Bereiche zu, nenne die Grenze und sage, was außerhalb liegt.',
},
{
id: 'implement',
title: 'Umsetzung',
body: 'Ich setze um, was vereinbart ist — allein, oder gemeinsam mit einem Partner aus dem Netzwerk, wenn das die bessere Lösung ergibt.',
},
{
id: 'operate',
title: 'Betrieb oder Übergabe',
body: 'Der Stand bleibt bedienbar. Übergabe heißt nachvollziehbare Strukturen und benannte Zuständigkeiten, kein undokumentierter Zwischenstand.',
},
],
proofCaseId: 'roesterei',
proofHeading: 'Ein belegter Weg vom Netz bis zum Shop',
proofNote: 'Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
featuredCaseIds: ['bannier', 'elbwaerme', 'hdi', 'innofocus'],
sections: [
{
id: 'areas',
headline: 'Vier Bereiche, eine Reihenfolge',
body: [
'Server und Netz tragen den Plattformbetrieb, darauf läuft die Produktsoftware mit ihrer Datenarbeit, und KI-Abläufe hängen an dieser Kette. Was nicht dazugehört, benenne ich im Briefing, statt es stillschweigend mitzuverkaufen.',
'Diese Reihenfolge ist auch die Reihenfolge der Diagnose. Ein Fehler weiter unten wird weiter oben nur teurer, nicht kleiner.',
],
},
{
id: 'team',
headline: 'Wer hier arbeitet',
body: [
'Ich bin Dein technischer Ansprechpartner, vom ersten Gespräch bis in den Betrieb, ohne Zwischenschicht.',
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Kaufmännisches und Organisation; er ist ausdrücklich kein zweiter Techniker und kein zweiter Entwickler. Dazu kommt ein ausgesuchtes Netzwerk: Passt jemand anderes besser, vermittle ich offen weiter oder wir setzen gemeinsam um.',
],
},
{
id: 'proof',
headline: 'Warum die Rösterei als Beleg steht',
body: [
'Die Rösterei Tangermünde zeigt die Kette vom Netz über Microsoft 365 bis zum Onlineshop und zu zwei internen KI-Werkzeugen.',
'Die anderen Fälle tragen jeweils einen Bereich. Sie stehen mit Zeitraum und Umfang da, mehr nicht.',
],
},
],
ctas: [
{ label: 'Projektbriefing starten', routeId: 'contact' },
{ label: 'Leistungen ansehen', routeId: 'services' },
{ label: 'Server und Netz', routeId: 'services', fragment: 'infrastructure-network' },
{ label: 'Fall Rösterei Tangermünde', routeId: 'projects', fragment: 'roesterei' },
],
};

View File

@@ -0,0 +1,375 @@
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:
'Sechs öffentliche Fälle: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty und die Magdeburger Dev Days. Der vollständige Lebenslauf liegt als Download bereit.',
hero: {
headline: 'Sechs öffentliche Fälle, bewusst ausgewählt',
proof:
'Drei laufende Mandate und drei abgeschlossene Projekte. Der vollständige Lebenslauf steht als PDF zum Download bereit.',
},
sections: [
{
id: 'selection',
headline: 'Warum diese sechs',
body: [
'Öffentlich stehen Innofocus / reifen.com, die Rösterei Tangermünde, die Fahrschule Bannier, Elbwärme, HDI Specialty und die Magdeburger Dev Days. Zusammen decken sie die vier Leistungsbereiche ab: laufende Infrastruktur, Plattformbetrieb, Produktsoftware mit Datenarbeit und die beiden umgesetzten KI-Werkzeuge.',
],
},
{
id: 'reading',
headline: 'Was jeder Fall festhält',
body: [
'Jeder Fall nennt Auftrag, Rolle, Zeitraum, Lage, Vorgehen und Ergebnis. Zahlen stehen nur dort, wo das Projekt sie hergegeben hat.',
],
},
],
ctas: [
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Kontakt', routeId: 'contact' },
],
caseLabels: {
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 Microsoft SQL Server und T-SQL. Container und Cluster mit Docker, Kubernetes und Azure AKS. Pipelines in GitLab CI/CD und Azure DevOps, GitOps mit Argo CD. Netz, Server und Virtualisierung vor Ort, dazu Microsoft 365 im laufenden Betrieb.',
],
},
],
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 sechs öffentlichen Fällen sind unter anderem Angular, TypeScript, SCSS, C#, .NET, Entity Framework, xUnit, Java mit Spring WebFlux, Microsoft SQL Server, T-SQL, MariaDB, Node.js, Docker, Kubernetes, Proxmox, Azure AKS, Azure SQL Server, Azure DevOps, GitLab CI/CD, Argo CD, Keycloak, Okta, OAuth 2, Microsoft 365 sowie Shopify mit Liquid und Hydrogen belegt. Die übrigen Einträge der Übersicht stehen für Breite und Vertrautheit, ohne öffentlichen Referenzfall.',
};
export const ABOUT_DE: PageCopy = {
routeId: 'about',
title: 'Über mich | Antonio Ledebuhr',
description:
'Antonio Ledebuhr, Fullstack- und DevOps-Ingenieur in Tangermünde. Persönlicher Ansprechpartner, Zweierteam mit Magdeburg, dazu ein ausgesuchtes Fachnetzwerk.',
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: [
'Berufliche Erfahrung mit dem Bau und Betrieb von Webanwendungen seit 2019. 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-team',
headline: 'Ansprechpartner, Team und Netzwerk',
body: [
'Ich bin der technische Ansprechpartner — vom ersten Anforderungsgespräch bis in den Produktivbetrieb. Direkter Kundenkontakt und Teamverantwortung gehören zur bisherigen Arbeit, nicht zu einer späteren Ausbaustufe.',
'Der Kern sind zwei Personen zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Papierkram, Kaufmännisches und Organisation; er ist kein zweiter Entwickler und kein zweiter Techniker.',
'Für Fragen außerhalb meines Zuschnitts gibt es ein ausgesuchtes Netzwerk. Ich vermittle offen weiter oder setze gemeinsam mit einem Partner um, wenn das die bessere Lösung ergibt.',
'Deutsch ist Muttersprache. Englisch ist verhandlungssicher.',
],
},
{
id: 'accent',
headline: 'Rösterei und Drehmoment',
body: [
'Die Rösterei-Arbeit bleibt der persönliche Akzent: Rösten und Drehmoment statt Leerlauf. Offenlegung: Ich bin wirtschaftlich an der Rösterei Tangermünde beteiligt.',
],
},
],
ctas: [
{ label: 'Projekte', routeId: 'projects' },
{ label: 'Für Recruiterinnen und Recruiter', routeId: 'pitch' },
{ label: 'Kontakt', routeId: 'contact' },
],
};
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. Ich antworte selbst, es sitzt kein Vertrieb dazwischen.',
],
},
],
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: 'infrastructure-network', label: 'Server, Netz und Virtualisierung' },
{ value: 'platform-operations', label: 'Plattform und Betrieb' },
{ value: 'software-data', label: 'Software und Daten' },
{ value: 'ai-workflows', label: 'KI-Abläufe' },
{ value: 'other', label: 'Sonstiges' },
],
},
{
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',
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 Links unten führen zurück in die Seite.',
},
sections: [],
ctas: [
{ label: 'Zur Startseite', routeId: 'home' },
{ label: 'Leistungen', routeId: 'services' },
{ label: 'Projekte', routeId: 'projects' },
{ label: 'Kontakt', routeId: 'contact' },
],
};

View File

@@ -0,0 +1,135 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_DE: PitchPageCopy = {
routeId: 'pitch',
title: 'Profil | Antonio Ledebuhr',
description:
'Fullstack- und DevOps-Ingenieur in Tangermünde: Webanwendungen bauen und betreiben, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
hero: {
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
proof:
'Bau und Betrieb von Webanwendungen, von der Anforderung bis in den Produktivbetrieb. Freiberuflich seit 04/2023.',
playfulLine: 'IT mit Drehmoment',
},
profile: [
'Bau und Betrieb von Webanwendungen als beruflicher Schwerpunkt, vom Datenmodell bis in den Produktivbetrieb.',
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
'Freiberuflich seit 04/2023, mit Sitz in Tangermünde.',
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
'Heute technischer Ansprechpartner in einem Zweierteam zwischen Tangermünde und Magdeburg; die kaufmännische Seite liegt beim Kollegen, die Technik bei mir. Dazu ein ausgesuchtes Fachnetzwerk.',
],
metrics: [
{
id: 'innofocus-migration-runtime',
value: '8 h → ca. 90 min',
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
caseId: 'innofocus',
},
],
timelineHeading: 'Stationen',
timeline: [
{
id: 'bannier',
period: 'seit etwa 2018',
role: 'IT-Betreuung und Betrieb bei der Fahrschule Bannier',
body: 'Die gesamte IT einer Fahrschule, einschließlich Betreuung und Betrieb der eingesetzten Fachsoftware. Diese Software stammt vom Hersteller; entwickelt habe ich sie nicht.',
},
{
id: 'devdays',
period: '08/2020 03/2022',
role: 'Fullstack-Entwickler, Magdeburger Dev Days',
body: 'Besucherseite und interne Planungswerkzeuge eines jährlichen Entwicklerevents, dazu eine Streamingplattform auf Kubernetes für die Ausgabe 2021.',
},
{
id: 'self-employed',
period: 'seit 12/2021',
role: 'unternehmerisch selbständig',
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb, dazu Rechnungsstellung und Kundenbeziehungen.',
},
{
id: 'freelance',
period: 'seit 04/2023',
role: 'freiberuflich',
body: 'Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
},
{
id: 'hdi',
period: '05/2023 01/2024',
role: 'Senior Fullstack / DevOps bei HDI Specialty',
body: 'Fullstack- und DevOps-Verantwortung in einem regulierten Versicherungsumfeld, einschließlich des Umzugs aller Umgebungen auf Azure AKS.',
},
{
id: 'roesterei',
period: 'seit 10/2023',
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
body: 'Ein Ansprechpartner für die gesamte Betriebstechnik; die wirtschaftliche Beteiligung bleibt offengelegt.',
},
{
id: 'innofocus',
period: 'seit 09/2025',
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
body: 'Alleinverantwortung für die Migration, als einziger Freiberufler im direkten Austausch mit dem Endkunden.',
},
{
id: 'elbwaerme',
period: 'seit 11/2025',
role: 'Infrastruktur und IT-Betreuung bei Elbwärme',
body: 'Infrastruktur, Microsoft 365 und Website; die Verantwortung liegt seit 11/2025 bei mir.',
},
],
stackHeading: 'Kernstack',
coreStack: [
{
id: 'java',
title: 'Java und Spring',
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
},
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
{
id: 'angular',
title: 'Angular und TypeScript',
body: 'Angular-SPAs, RxJS und SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
{
id: 'containers',
title: 'Docker und Kubernetes',
body: 'Container und Cluster vor Ort und in der Cloud.',
},
{
id: 'azure',
title: 'Azure einschließlich AKS',
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
},
{
id: 'cicd',
title: 'GitLab CI/CD und Azure DevOps',
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
sections: [
{
id: 'profile',
headline: 'In dreißig Sekunden',
body: [
'Ich arbeite als Fullstack- und DevOps-Ingenieur aus Tangermünde, freiberuflich seit 04/2023. Der Lebenslauf als PDF führt die Stationen vollständig.',
],
},
{
id: 'featured-cases',
headline: 'Ausgewählte Fälle',
body: [
'Die öffentlichen Fälle decken eCommerce und ERP, laufende Infrastruktur und Betrieb, Versicherung und eine Veranstaltungsplattform ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
],
},
],
ctas: [
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

View File

@@ -0,0 +1,416 @@
import { type ServicesPageCopy } from '../content.contracts';
export const SERVICES_DE: ServicesPageCopy = {
routeId: 'services',
title: 'Leistungen | Antonio Ledebuhr',
description:
'Server, Netze und Virtualisierung, Plattformbetrieb, Produktsoftware mit Datenarbeit und KI-Abläufe — ein Ansprechpartner von der Lage bis zum Betrieb.',
hero: {
headline: 'Vier Bereiche, ein Ansprechpartner',
proof:
'Server, Netze und Virtualisierung, Plattformbetrieb mit Microsoft 365 und Kubernetes, Produktsoftware mit SQL-Datenarbeit und KI-Abläufe. Jeder Bereich läuft von der Lage über Diagnose und Umsetzung bis in den Betrieb.',
playfulLine: 'Vier Gänge, ein Antriebsstrang',
body: [
'Du beschreibst, was im Unternehmen klemmt oder wohin es gehen soll. Ich ordne das einem der vier Bereiche zu, sage, was dazugehört, und benenne, was außerhalb liegt.',
'Jeder Bereich hat denselben Aufbau: Lage, Diagnose, Umsetzung, Betrieb und Übergabe, danach der Umfang des Angebots, ein öffentlicher Beleg und der Weg zur Anfrage. Über die Sprungmarken kommst Du direkt in den Bereich, der zu Deiner Lage passt.',
],
},
sections: [
{
id: 'approach',
headline: 'Wie ich vorgehe',
body: [
'Ich fange bei der Schicht an, die trägt: Netz, Server, Virtualisierung. Darüber liegen die Plattformdienste, darauf die Produktsoftware mit ihrer Datenarbeit, und ganz oben hängen KI-Abläufe an dem, was darunter läuft.',
'Diese Reihenfolge ist kein Verkaufsweg, sondern eine Diagnosereihenfolge. Ein Fehler weiter unten wird weiter oben nur teurer, nicht kleiner.',
],
},
{
id: 'team',
headline: 'Wer hier arbeitet',
body: [
'Ich bin Dein technischer Ansprechpartner. Vom ersten Gespräch über die Umsetzung bis in den Betrieb sprichst Du mit derselben Person, und diese Person hat die Entscheidungen auch getroffen.',
'Der Kern sind zwei Personen, zwischen Tangermünde und Magdeburg. Mein Kollege kommt aus der Betriebswirtschaft und übernimmt Papierkram, Kaufmännisches und Organisation. Er ist ausdrücklich kein zweiter Entwickler und kein zweiter Techniker — die Technik liegt bei mir.',
'Dazu kommt ein ausgesuchtes Netzwerk aus Spezialistinnen und Spezialisten. Passt ein Vorhaben besser dorthin, sage ich das offen und vermittle weiter, oder wir setzen es gemeinsam um. Über die Antwort entscheidet die Eignung, nicht meine freie Zeit.',
],
},
{
id: 'boundaries',
headline: 'Was ich nicht mitverkaufe',
body: [
'Was außerhalb eines Bereichs liegt, steht im Briefing und nicht im Kleingedruckten. Wenn eine Änderung nicht nötig ist oder sich der Aufwand nicht lohnt, hörst Du das von mir, bevor jemand eine Rechnung schreibt.',
'Umgesetzte Arbeit und Angebot sind unten in jedem Bereich getrennt ausgewiesen. Was als Angebot dasteht, wird je Auftrag geschnitten und geprüft — es steht nicht für Kundenprojekte, die es so noch nicht gibt.',
],
},
],
ctas: [
{ label: 'Server und Netz', routeId: 'services', fragment: 'infrastructure-network' },
{ label: 'Plattform und Betrieb', routeId: 'services', fragment: 'platform-operations' },
{ label: 'Software und Daten', routeId: 'services', fragment: 'software-data' },
{ label: 'KI-Abläufe', routeId: 'services', fragment: 'ai-workflows' },
{ label: 'Projektbriefing starten', routeId: 'contact' },
{ label: 'Öffentliche Projekte', routeId: 'projects' },
],
sectionsHeading: 'Die vier Bereiche',
deliveredOfferingsHeading: 'Umgesetzte Arbeit',
offerOfferingsHeading: 'Angebot, je Auftrag geprüft',
offeringCaseBackedLabel: 'Belegt durch den öffentlichen Fall {case}.',
evidenceHeading: 'Belege aus der öffentlichen Arbeit',
inquiryHeading: 'Anfrage',
systemsMap: {
heading: 'Wie die vier Bereiche zusammenhängen',
intro:
'Server und Netz tragen den Plattformbetrieb, darauf läuft die Produktsoftware mit ihrer Datenarbeit, und die KI-Abläufe hängen an dieser Kette. Die Projektknoten zeigen, wo die öffentlichen Fälle ansetzen.',
},
serviceSections: [
{
id: 'infrastructure-network',
title: 'Server, Netz und Virtualisierung',
lead: 'Die Schicht, auf der der Rest aufsetzt: Netzstrukturen, Server und Virtualisierung vor Ort, geplant auf Kosten und Wartbarkeit.',
situation: {
id: 'infrastructure-network-situation',
headline: 'Lage',
body: [
'Das Netz ist gewachsen, nicht geplant. Ein Server steht im Nebenraum, ein zweiter irgendwo im Rechenzentrum, und niemand kann sicher sagen, welcher Dienst auf welchem Blech läuft.',
'Meist fällt das erst auf, wenn etwas ausfällt oder ein Umbau ansteht: ein neuer Standort, mehr Fläche, mehr Leute, ein Dienst, der plötzlich verlässlich erreichbar sein muss.',
],
},
diagnosis: {
id: 'infrastructure-network-diagnosis',
headline: 'Diagnose',
body: [
'Zuerst nehme ich auf, was tatsächlich läuft: Wege im Netz, Server und ihre Rollen, Virtualisierung, Abhängigkeiten und die Stellen, an denen ein einzelner Ausfall den Betrieb anhält.',
'Daraus wird eine kurze, prüfbare Einschätzung: was bleiben kann, was ersetzt gehört und was für seinen Nutzen schlicht zu teuer ist. Wenn keine Änderung nötig ist, sage ich das auch.',
],
},
implementation: {
id: 'infrastructure-network-implementation',
headline: 'Umsetzung',
body: [
'Netz- und Serverstrukturen werden so gebaut, dass sie zum Haus passen: klare Segmente, dokumentierte Wege, Virtualisierung dort, wo sie Hardware und Wartung spart — bei der Rösterei Tangermünde zum Beispiel mit Proxmox.',
'On-Premise bleibt eine bewusste Entscheidung, keine Notlösung. Wo eigene Hardware günstiger und wartbarer ist als ein Mietdienst, wird sie eingesetzt. Wo es umgekehrt ist, sage ich das.',
],
},
operation: {
id: 'infrastructure-network-operation',
headline: 'Betrieb und Übergabe',
body: [
'Danach läuft die Struktur unter meiner Betreuung weiter oder geht dokumentiert an Deine IT über. Übergabe heißt: nachvollziehbare Strukturen und benannte Zuständigkeiten, keine undokumentierte Sonderlocke.',
'Zum laufenden Betrieb gehören nachgeordnet auch die Arbeitsplatzrechner und Mitarbeitergeräte, die an dieser Infrastruktur hängen — Einrichtung, Austausch und Support im Alltag, sofern das mit beauftragt ist.',
],
},
offerings: [
{
id: 'infra-network-and-server-structures',
title: 'Netz- und Serverstrukturen',
body: 'Aufbau und Betreuung von Netzwerk- und Serverstrukturen für kleine und mittlere Betriebe, vom Segmentschnitt bis zur dokumentierten Verkabelung. Bei der Rösterei Tangermünde liegt diese Schicht seit 10/2023 bei mir.',
status: 'delivered',
referenceCaseId: 'roesterei',
},
{
id: 'infra-virtualisation-on-premise',
title: 'Virtualisierung vor Ort',
body: 'Mehrere Dienste auf eigener Hardware statt auf mehreren Blechen. Virtualisierung mit Proxmox macht Wartung, Umzug und Wiederanlauf planbar, ohne die Struktur aus dem Haus zu geben.',
status: 'delivered',
referenceCaseId: 'roesterei',
},
{
id: 'infra-whole-it-small-business',
title: 'IT-Gesamtbetreuung für kleine Betriebe',
body: 'Ein Ansprechpartner für die gesamte IT eines Betriebs: Netz, Server, laufender Support und der Betrieb der eingesetzten Fachsoftware. Bei der Fahrschule Bannier läuft das seit etwa 2018. Die dortige Fachsoftware betreue und betreibe ich; entwickelt habe ich sie nicht.',
status: 'delivered',
referenceCaseId: 'bannier',
},
{
id: 'infra-infrastructure-responsibility',
title: 'Infrastruktur in einer Verantwortung',
body: 'Infrastruktur, die Dienste für die tägliche Arbeit und die Zugänge dazu unter einer Verantwortung, statt drei getrennter Vereinbarungen. Bei Elbwärme liegt das seit 11/2025 bei mir.',
status: 'delivered',
referenceCaseId: 'elbwaerme',
},
{
id: 'infra-inventory-and-risk-review',
title: 'Bestandsaufnahme und Ausfallanalyse',
body: 'Eine geführte Aufnahme der bestehenden Infrastruktur mit schriftlicher Einschätzung, an welchen Stellen ein einzelner Ausfall den Betrieb anhalten würde. Umfang und Tiefe werden vor Beginn festgelegt.',
status: 'offer',
},
],
evidence: {
id: 'infrastructure-network-evidence',
heading: 'Belege aus der öffentlichen Arbeit',
note: 'Die Fahrschule Bannier begleite ich seit etwa 2018 mit der gesamten IT, dem laufenden Support und dem Betrieb der vorhandenen Fachsoftware. Bei Elbwärme liegt die Infrastruktur seit 11/2025 bei mir, bei der Rösterei Tangermünde seit 10/2023 einschließlich Netz-, Server- und Virtualisierungsschicht.',
caseIds: ['bannier', 'elbwaerme', 'roesterei'],
},
inquiry: {
id: 'infrastructure-network-inquiry',
headline: 'Anfrage',
body: [
'Für eine Infrastrukturanfrage hilft wenig Technik und viel Alltag: wie viele Leute damit arbeiten, wo Server und Netzwerktechnik heute stehen, was regelmäßig ausfällt oder aufhält, und ob ein Umzug oder Umbau ansteht.',
'Das Briefing bleibt im Browser. Der Knopf öffnet nur eine vorausgefüllte Nachricht im lokalen E-Mail-Programm; an diesen Server wird nichts gesendet.',
],
},
},
{
id: 'platform-operations',
title: 'Plattform und Betrieb',
lead: 'Die Dienste und Umgebungen, auf denen der Alltag läuft: Microsoft 365, Container und Cluster, Pipelines — und die Frage, wer das danach betreibt.',
situation: {
id: 'platform-operations-situation',
headline: 'Lage',
body: [
'Anwendungen laufen auf einzelnen Hosts oder in einem PaaS-Dienst. Entwicklungs-, Test- und Produktivumgebung weichen voneinander ab, und ein Release hängt an Handgriffen, die eine einzelne Person im Kopf hat.',
'Auf der anderen Seite steht das Tagesgeschäft: Postfächer, Ablagen, Rechte, Konten für neue Kolleginnen und Kollegen. Auch das ist Plattformbetrieb, nur unter anderem Namen.',
],
},
diagnosis: {
id: 'platform-operations-diagnosis',
headline: 'Diagnose',
body: [
'Ich schaue, welche Umgebungen wirklich identisch sein müssen, welche Last ein Cluster tragen soll und ob der Betriebsaufwand sich lohnt. Nicht jeder Dienst gehört in ein Kubernetes-Cluster.',
'Bei Microsoft 365 geht es um Konten, Rechte und Ablagen: wer kommt an was, was passiert beim Eintritt und beim Austritt, und welche Einstellung ist seit Jahren nie wieder angefasst worden.',
],
},
implementation: {
id: 'platform-operations-implementation',
headline: 'Umsetzung',
body: [
'Containerisierung mit Docker, Betrieb auf Kubernetes vor Ort oder auf Azure AKS, Pipelines in Azure DevOps oder GitLab CI/CD, GitOps-Deployments über Argo CD, wenn der Auftrag das hergibt.',
'Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS umgezogen. Microsoft 365 richte ich so ein, dass Konten und Rechte einem nachvollziehbaren Muster folgen statt gewachsenen Einzelfällen.',
],
},
operation: {
id: 'platform-operations-operation',
headline: 'Betrieb und Übergabe',
body: [
'Der Stand soll sich aus dem Repository erklären, nicht aus einem einzelnen Rechner. Wer nach mir hineinschaut, findet Pipelines, Manifeste und Zugänge dort, wo er sie vermutet.',
'Zum Betrieb gehören nachgeordnet auch die Endgeräte, die auf diese Dienste zugreifen: Einrichtung, Konten, Zugriffsrechte und der Support im Alltag, sofern das mit beauftragt ist.',
],
},
offerings: [
{
id: 'platform-microsoft-365',
title: 'Microsoft 365 einrichten und betreiben',
body: 'Konten, Postfächer, Ablagen und Rechte nach einem Muster, das auch beim nächsten neuen Konto noch trägt. Bei der Rösterei Tangermünde und bei Elbwärme läuft Microsoft 365 unter meiner Verantwortung.',
status: 'delivered',
referenceCaseId: 'roesterei',
},
{
id: 'platform-containers-and-kubernetes',
title: 'Container und Kubernetes-Cluster',
body: 'Anwendungen containerisieren und auf einem Cluster betreiben, vor Ort oder auf Azure AKS. Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services nach Azure AKS umgezogen.',
status: 'delivered',
referenceCaseId: 'hdi',
},
{
id: 'platform-pipelines-and-gitops',
title: 'CI/CD-Pipelines und GitOps',
body: 'Bauen, Testen und Ausrollen als wiederholbarer Vorgang: Pipelines in Azure DevOps oder GitLab CI/CD, Deployments über Argo CD. Bei Innofocus / reifen.com läuft die Anwendung auf diesem Weg auf Kubernetes.',
status: 'delivered',
referenceCaseId: 'innofocus',
},
{
id: 'platform-aligning-environments',
title: 'Umgebungen angleichen',
body: 'Entwicklungs-, Test- und Produktivumgebung auf denselben Stand bringen, damit ein Fehler nicht erst in der Produktion sichtbar wird. Der Zuschnitt hängt an der vorhandenen Werkzeugkette und wird vorab festgelegt.',
status: 'offer',
},
{
id: 'platform-handover-to-your-team',
title: 'Betriebsübergabe an Dein Team',
body: 'Wenn der Betrieb intern weitergehen soll, wird er dokumentiert und begleitet übergeben: Zugänge, Abläufe, wiederkehrende Aufgaben, Ansprechwege. Der Umfang wird je Auftrag vereinbart.',
status: 'offer',
},
],
evidence: {
id: 'platform-operations-evidence',
heading: 'Belege aus der öffentlichen Arbeit',
note: 'Bei HDI Specialty sind Entwicklungs-, QS- und Produktivumgebung von Azure App Services auf ein Kubernetes-Cluster auf Azure AKS umgezogen, mit CI/CD über Azure DevOps. Bei der Rösterei Tangermünde laufen Microsoft 365, Docker, Kubernetes und GitLab CE unter derselben Verantwortung wie der Onlineshop. Bei Elbwärme gehört Microsoft 365 seit 11/2025 zur laufenden Betreuung.',
caseIds: ['hdi', 'roesterei', 'elbwaerme'],
},
inquiry: {
id: 'platform-operations-inquiry',
headline: 'Anfrage',
body: [
'Für eine Plattformanfrage sind drei Angaben nützlich: worauf die Anwendungen heute laufen, wie ein Release heute abläuft, und ob der Betrieb später intern oder bei mir liegen soll.',
'Das Briefing bleibt im Browser und öffnet nur das lokale E-Mail-Programm. An diesen Server wird nichts übertragen.',
],
},
},
{
id: 'software-data',
title: 'Software und Daten',
lead: 'Produktsoftware von der Fachlichkeit über die Schnittstelle bis zur Oberfläche — und die Datenarbeit, die meistens der eigentliche Aufwand ist.',
situation: {
id: 'software-data-situation',
headline: 'Lage',
body: [
'Eine Anwendung soll neu entstehen oder ein bestehendes System soll abgelöst werden. Sichtbar ist die Oberfläche; der Aufwand steckt in der Fachlichkeit und in den Altdaten.',
'Häufig kommt beides zusammen: ein neues, sauber geschnittenes System auf der einen Seite, ein gewachsener Datenbestand auf der anderen, der vollständig und prüfbar mitkommen muss.',
],
},
diagnosis: {
id: 'software-data-diagnosis',
headline: 'Diagnose',
body: [
'Zuerst der fachliche Schnitt, dann das Datenmodell, dann die Schnittstellen. Domain-driven Design und Tests machen die Annahmen sichtbar, bevor Code in die Breite geht.',
'Bei Migrationen lese ich zuerst den Altbestand, statt den Zielentwurf zu verteidigen: Welche Felder tragen wirklich Bedeutung, welche Dubletten gibt es, und was muss für eine prüfende Stelle dokumentiert sein?',
],
},
implementation: {
id: 'software-data-implementation',
headline: 'Umsetzung',
body: [
'Backend in Java mit Spring Boot oder in C# mit .NET, REST-Schnittstellen, Spring Data JPA oder Entity Framework, Angular und TypeScript auf der Oberfläche, testgetriebene Entwicklung mit JUnit oder xUnit.',
'Datenarbeit in Microsoft SQL Server mit T-SQL: Stored Procedures für Transformation, Normalisierung und Bereinigung. Anmeldung und Berechtigungen über Keycloak oder Okta mit OAuth 2 und OIDC.',
],
},
operation: {
id: 'software-data-operation',
headline: 'Betrieb und Übergabe',
body: [
'Ausgeliefert wird ein Stand, der sich selbst erklärt: Tests, Migrationsskripte und Schnittstellen liegen im Repository, nicht in einem Kopf. Wo eine prüfende Stelle mitliest, entsteht die Dokumentation gleich mit.',
'Danach betreue ich die Anwendung weiter oder übergebe sie an Dein Team — mit Zugängen, Abläufen und dem Wissen, das für den nächsten Ausbau nötig ist.',
],
},
offerings: [
{
id: 'software-fullstack-product-software',
title: 'Fullstack-Produktsoftware',
body: 'Neue Anwendungen von der Domäne bis zur Oberfläche: Angular im Frontend, C# und .NET oder Java und Spring im Backend, REST-Schnittstellen, testgetriebene Entwicklung. Bei HDI Specialty ist auf diesem Weg die Software für das Exposure Management entstanden.',
status: 'delivered',
referenceCaseId: 'hdi',
},
{
id: 'software-legacy-data-migration',
title: 'Datenmigration und Normalisierung',
body: 'Altbestände in eine neue, normalisierte Struktur überführen, prüfbar und wiederholbar — auch dann, wenn ein anderer Dienstleister damit angefangen hat. Bei Innofocus / reifen.com liegt die Migration im Microsoft SQL Server allein bei mir; ein Lauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität.',
status: 'delivered',
referenceCaseId: 'innofocus',
},
{
id: 'software-sql-and-tsql-data-work',
title: 'SQL- und T-SQL-Datenarbeit',
body: 'Stored Procedures, Transformation, Bereinigung und Auswertung direkt im Microsoft SQL Server — dort, wo die Daten liegen, statt über Umwege in der Anwendung.',
status: 'delivered',
referenceCaseId: 'innofocus',
},
{
id: 'software-replacing-a-legacy-system',
title: 'Bestandssystem ablösen',
body: 'Ein gewachsenes System schrittweise durch ein neues ersetzen, ohne den laufenden Betrieb anzuhalten. Reihenfolge, Schnitt und Rückfallweg werden vor der ersten Zeile Code festgelegt.',
status: 'offer',
},
{
id: 'software-data-model-and-api-review',
title: 'Datenmodell und Schnittstellen prüfen',
body: 'Eine schriftliche Einschätzung zu Datenmodell, Schnittstellen und Testabdeckung einer bestehenden Anwendung, mit benannten Risiken und einer Reihenfolge. Der Umfang wird vorab festgelegt.',
status: 'offer',
},
],
evidence: {
id: 'software-data-evidence',
heading: 'Belege aus der öffentlichen Arbeit',
note: 'Bei Innofocus / reifen.com verantworte ich seit 09/2025 die vollständige Migration der Altdaten in ein neues, normalisiertes System; ein Migrationslauf dauert rund 90 Minuten statt zuvor etwa acht Stunden, bei größerem Funktionsumfang und höherer Datenqualität. Bei den Magdeburger Dev Days entstanden Besucherseite und interne Planungswerkzeuge als Fullstack-Arbeit. Bei HDI Specialty lag die Software für das Exposure Management einschließlich der BaFin-Auswertungen in meiner Verantwortung.',
caseIds: ['innofocus', 'devdays', 'hdi'],
},
inquiry: {
id: 'software-data-inquiry',
headline: 'Anfrage',
body: [
'Für eine Softwareanfrage: was das System können soll, welches System es ersetzt oder ergänzt, wo die Daten heute liegen und ob eine prüfende Stelle mitliest.',
'Das Briefing bleibt im Browser und erzeugt nur eine lokale E-Mail. An diesen Server wird nichts gesendet.',
],
},
},
{
id: 'ai-workflows',
title: 'KI-Abläufe',
lead: 'Zwei umgesetzte Werkzeuge und ein klar benannter Rest. Was KI hier leistet, hängt am konkreten Ablauf und wird je Auftrag geprüft.',
situation: {
id: 'ai-workflows-situation',
headline: 'Lage',
body: [
'Ein interner Ablauf kostet Zeit, ohne dass dabei jemand etwas entscheidet: Bewertungen sortieren, immer gleiche Schritte in der Verwaltung, Texte in eine Form bringen.',
'Gleichzeitig steht die Frage im Raum, ob überhaupt ein Modell nötig ist. Manchmal reicht eine Regel, eine Abfrage oder ein zusätzlicher Knopf an der richtigen Stelle.',
],
},
diagnosis: {
id: 'ai-workflows-diagnosis',
headline: 'Diagnose',
body: [
'Zuerst der Ablauf, dann die Datengrenze, dann das Werkzeug. Ich schaue, welche Schritte wirklich wiederkehren, welche Daten das Haus nicht verlassen dürfen und an welcher Stelle eine Person die Entscheidung behalten muss.',
'Erst danach steht fest, ob ein externes Modell, ein lokal betriebenes Modell oder gar kein Modell die passende Antwort ist. Das Ergebnis kann auch lauten, dass der Aufwand sich nicht lohnt.',
],
},
implementation: {
id: 'ai-workflows-implementation',
headline: 'Umsetzung',
body: [
'Wo ein Auftrag es hergibt, entsteht ein schmales Werkzeug innerhalb der bestehenden Kette — nicht ein Chatfenster neben dem eigentlichen System. Bei der Rösterei Tangermünde sind auf diesem Weg zwei Werkzeuge entstanden, angebunden an die interne Werkzeugkette.',
'Was darüber hinausgeht, wird je Auftrag geschnitten, begrenzt und geprüft. Ich weise es hier als Angebot aus, nicht als vorhandene Kundenerfahrung.',
],
},
operation: {
id: 'ai-workflows-operation',
headline: 'Betrieb und Übergabe',
body: [
'Ein KI-Werkzeug bleibt Teil der internen Werkzeugkette und wird wie jede andere Anwendung betrieben: versioniert, dokumentiert, abschaltbar. Wenn es nicht mehr trägt, kommt es weg.',
'Zur Übergabe gehört, welches Modell angebunden ist, welche Daten es sieht, was protokolliert wird und an welcher Stelle ein Mensch freigibt.',
],
},
offerings: [
{
id: 'ai-review-categorisation',
title: 'Kategorisierung von Kundenbewertungen',
body: 'Ein KI-Werkzeug, das eingehende Kundenbewertungen kategorisiert. Umgesetzt für die Rösterei Tangermünde und dort in die interne Werkzeugkette eingebunden.',
status: 'delivered',
referenceCaseId: 'roesterei',
},
{
id: 'ai-shop-management-assistant',
title: 'Assistent für die Shop-Verwaltung',
body: 'Ein KI-Werkzeug für die schnelle Verwaltung des neuen Onlineshops, vergleichbar mit Shopify Sidekick und ebenfalls in die internen Werkzeuge integriert. Umgesetzt für die Rösterei Tangermünde.',
status: 'delivered',
referenceCaseId: 'roesterei',
},
{
id: 'ai-process-analysis-first',
title: 'Prozessanalyse vor dem Modell',
body: 'Gemeinsames Zuschneiden des Ablaufs, bevor überhaupt ein Modell ins Spiel kommt. Nutzen und Umfang werden am konkreten Auftrag geprüft und nicht aus anderen Projekten übertragen.',
status: 'offer',
},
{
id: 'ai-external-or-local-models',
title: 'Anbindung externer oder lokaler Modelle',
body: 'Ein externes Modell über eine API anbinden oder prüfen, ob ein lokal betriebenes Modell für den Auftrag tragfähig ist. Beides ist eine Klärung am konkreten Fall, keine Darstellung allgemeiner Produktionserfahrung.',
status: 'offer',
},
{
id: 'ai-retrieval-with-data-boundary',
title: 'RAG mit Datengrenze',
body: 'Wenn interne Texte ein Modell stützen sollen, wird RAG — der Abruf angereicherter Texte — samt Datengrenze am konkreten Bestand entworfen. Der Zuschnitt gilt nur für diesen Auftrag und wird mit der Fachseite geprüft.',
status: 'offer',
},
{
id: 'ai-human-approval-and-multi-step',
title: 'Freigabe durch Menschen und mehrstufige Abläufe',
body: 'Ein Vorschlag, an welchen Stellen eine Person freigibt oder korrigiert und wie mehrere spezialisierte Schritte nacheinander laufen könnten. Beides wird am Auftrag skizziert und geprüft; es steht nicht für vorhandene Projekte dieser Art.',
status: 'offer',
},
],
evidence: {
id: 'ai-workflows-evidence',
heading: 'Belege aus der öffentlichen Arbeit',
note: 'Umgesetzt sind genau zwei KI-Werkzeuge, beide bei der Rösterei Tangermünde: die Kategorisierung von Kundenbewertungen und ein Assistent für die schnelle Shop-Verwaltung, vergleichbar mit Shopify Sidekick und in die internen Werkzeuge eingebunden. Beide laufen im internen Betrieb, nicht als Produkt. Was in diesem Bereich sonst aufgeführt ist, ist Angebot und wird je Auftrag geprüft.',
caseIds: ['roesterei'],
},
inquiry: {
id: 'ai-workflows-inquiry',
headline: 'Anfrage',
body: [
'Für eine KI-Anfrage: welcher Ablauf entlastet werden soll, wie oft er vorkommt, welche Daten dabei anfallen und welche davon das Haus nicht verlassen dürfen.',
'Das Briefing bleibt im Browser und öffnet nur das lokale E-Mail-Programm. An diesen Server wird nichts gesendet — was für ein Gespräch über Daten der ehrlichere Anfang ist.',
],
},
},
],
};

View File

@@ -0,0 +1,46 @@
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_DE } from './services';
export const SITE_CONTENT_DE: SiteContent = {
home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_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: 'IT-Infrastruktur, Plattformbetrieb und Softwareentwicklung',
professionalServiceDescription:
'Server und Netze, Plattformbetrieb, Produktsoftware mit Datenarbeit und KI-Abläufe — mit einem technischen Ansprechpartner vom ersten Briefing bis in den Betrieb.',
},
pages: {
home: HOME_DE,
pitch: PITCH_DE,
services: SERVICES_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,263 @@
import { type CaseStudyCopy, type CaseStudyId } from '../content.contracts';
export const CASES_EN: Record<CaseStudyId, CaseStudyCopy> = {
innofocus: {
id: 'innofocus',
client: 'Innofocus / reifen.com',
role: 'Senior backend and database engineer',
period: 'since September 2025, ongoing',
domain: 'eCommerce / ERP',
headline: 'A grown ERP data store moved into a normalised system',
summary:
'The existing ERP is being replaced by a new, normalised system. All legacy data is migrated directly inside Microsoft SQL Server.',
responsibility:
'Sole responsibility for the migration — the migration team is one person. As the only freelancer on it, I deal with the end customer directly.',
situation:
'The legacy structure is not normalised and the target structure is, so the migration is largely data cleansing, normalisation and documentation for an auditor. Another service provider had run the migration before me; I took it over and continued from what was there.',
approach: [
'Extensive T-SQL stored procedures for the transformation, normalisation and cleansing of the legacy data.',
'Selective work on the application itself with C#, .NET and Angular.',
'Authentication and authorisation through Keycloak with OAuth 2 and OIDC.',
'The application runs on Kubernetes, with GitOps deployments through Argo CD.',
'Version control with Git, pipelines and project management on Azure DevOps.',
],
outcome: [
'The migration run takes about 90 minutes instead of around eight hours, at a larger feature scope and higher data quality.',
'The target structure is normalised and documented for the auditor.',
],
metrics: [
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'at a larger feature scope and higher data quality',
caseId: 'innofocus',
},
],
stack: [
'Microsoft SQL Server',
'T-SQL',
'C#',
'.NET',
'Angular',
'Keycloak',
'OAuth 2 / OIDC',
'Kubernetes',
'Argo CD',
'Azure DevOps',
],
tags: ['eCommerce', 'ERP', 'data migration', 'SQL', 'Kubernetes'],
linkLabel: 'Read the case: Innofocus / reifen.com',
teaser:
'Sole responsibility for an ERP data migration in SQL Server. The run went from around eight hours to about 90 minutes.',
},
roesterei: {
id: 'roesterei',
client: 'Rösterei Tangermünde',
role: 'Full technical responsibility',
period: 'since October 2023, ongoing',
domain: 'eCommerce and internal IT',
headline: 'Network, servers, shop and internal tools under one responsibility',
summary:
"The brief started as a new online shop. It grew into responsibility for the company's technology as a whole.",
responsibility:
'The whole of the technology sits with me: network structures, server structures, employee devices, management SaaS such as Microsoft 365, the new online shop and the new marketing.',
situation:
'The brief was a new online shop. Very dynamic circumstances pushed the scope well past that. Requirements come straight from people who do not work in technology, and the brand side is worked out together with a designer, so every technical decision has to be explainable in plain language.',
approach: [
'Contemporary, cost-effective infrastructure instead of added complexity: network and server structures, virtualisation on Proxmox, Docker and Kubernetes, GitLab CE, Microsoft 365.',
'Shop version 1 on Shopify, with a custom theme and custom components in Liquid.',
'Shop version 2 with Liquid and Hydrogen, with our own services integrated into it.',
'Internal tools for lower cost and more autonomy, among them a generator for the vector graphics used in advertising material.',
'Two AI tools in daily use: one categorises customer reviews, one speeds up managing the new shop — comparable to Shopify Sidekick and integrated into the same internal tooling.',
],
outcome: [
"The company's technology has one owner, from the network through to the storefront.",
'Two AI tools run in internal use: the review categorisation and the shop assistant.',
],
metrics: [],
stack: [
'Shopify',
'Liquid',
'Hydrogen',
'Angular',
'TypeScript',
'SCSS',
'Java Spring WebFlux',
'Java OpenAI Library',
'MariaDB',
'Docker',
'Kubernetes',
'Proxmox',
'GitLab CE',
'Microsoft 365',
],
tags: ['eCommerce', 'internal IT', 'infrastructure', 'Shopify', 'AI tools'],
linkLabel: 'Read the case: Rösterei Tangermünde',
transparencyNote: 'I hold an economic participation in Rösterei Tangermünde.',
teaser:
'Full technical responsibility for one company: network, servers, Microsoft 365, the shop, and two AI tools in internal use.',
},
bannier: {
id: 'bannier',
client: 'Fahrschule Bannier',
role: 'IT support and operation',
period: 'since roughly 2018, ongoing',
domain: 'Driving school / internal IT',
headline: 'The whole IT of a driving school, kept running year after year',
summary:
'One contact for the entire IT of a driving school: the computers, the network, the website, and the support and operation of the specialist software the school already uses.',
responsibility:
'I support and operate the existing driving-school software. I did not develop it — it is a third-party product, and my responsibility is that it runs, not what is inside it. Alongside that I look after the computers, the rest of the IT and the website.',
situation:
'A driving school runs on specialist software it bought in, on the computers in its offices, and on a website its customers find it through. The software was not written by me and never has been; it comes from its vendor. What was missing was somebody to keep the whole thing working, and that has been my part since roughly 2018.',
approach: [
"Support and operation of the existing, third-party driving-school software: installation, updates, day-to-day problems, and dealing with the vendor when the issue belongs to them. Developing it is the vendor's job, not mine.",
'The computers and the rest of the on-site IT kept in working order, including replacement and upgrades.',
'Day-to-day support from a named contact, without a ticket chain in between.',
'The website of the driving school, published at fahrschule-stendal.de, operated and maintained.',
],
outcome: [
'The school has had one IT contact since roughly 2018, without an in-house IT department.',
"The specialist software stays operable in daily use; developing that software remains the vendor's job, not mine.",
],
metrics: [],
stack: [
'Workstations and on-site IT',
'Company network',
'Backups',
'Third-party driving-school software (operated and supported, not developed)',
'Website operation',
],
tags: ['internal IT', 'support', 'operation', 'website'],
linkLabel: 'Read the case: Fahrschule Bannier',
teaser:
'The whole IT of a driving school since roughly 2018. I support and operate its specialist software — I did not build it.',
externalUrl: 'https://fahrschule-stendal.de',
externalLabel: 'Visit fahrschule-stendal.de',
},
elbwaerme: {
id: 'elbwaerme',
client: 'Elbwärme',
role: 'Infrastructure and IT support',
period: 'since November 2025, ongoing',
domain: 'Infrastructure and internal IT',
headline: 'Infrastructure, Microsoft 365 and the website in one hand',
summary:
"Responsibility for the company's infrastructure, its Microsoft 365 environment and its website, since November 2025.",
responsibility:
'The infrastructure, Microsoft 365 and the website sit with me. The engagement started in November 2025, so this is a young one; it is listed for what it covers, not for how long it has run.',
situation:
'Elbwärme wanted one technical contact for the systems the company works with, rather than three separate arrangements. The scope is the infrastructure, the Microsoft 365 environment and the public website at elbwaerme.de, and it has been mine since November 2025.',
approach: [
'The infrastructure carried as one connected layer rather than a collection of separate devices.',
'Microsoft 365 as the managed layer for the accounts and the everyday services.',
'The public website at elbwaerme.de, operated and maintained.',
'One contact for all three, instead of a separate arrangement for each.',
],
outcome: [
'Infrastructure, Microsoft 365 and the website are handled by the same person.',
'The engagement has run since November 2025 and is ongoing. Nothing longer-standing is claimed for it.',
],
metrics: [],
stack: ['Microsoft 365', 'Company network', 'Server operation', 'Website operation'],
tags: ['infrastructure', 'Microsoft 365', 'operation', 'website'],
linkLabel: 'Read the case: Elbwärme',
teaser:
'Infrastructure, Microsoft 365 and the website for one company, since November 2025 and ongoing.',
externalUrl: 'https://elbwaerme.de',
externalLabel: 'Visit elbwaerme.de',
},
hdi: {
id: 'hdi',
client: 'HDI Specialty',
role: 'Senior fullstack / DevOps',
period: 'May 2023 January 2024',
domain: 'Insurance',
headline: 'Exposure management: simulations, stochastic evaluation and BaFin reporting',
summary:
'Software that automates simulation sequences to determine specific risks, evaluates them stochastically, collects policies centrally and produces evaluations across all policies for BaFin.',
responsibility:
'Senior fullstack and DevOps work on the exposure-management software, including the migration of the environments onto Azure AKS.',
situation:
'Exposure management in a regulated insurance setting: risks have to be determined through automated simulation sequences and evaluated stochastically, policies have to be collected centrally, and the whole policy set has to be reported to BaFin. Alongside that, the development, QA and production environments on Azure App Services had drifted apart.',
approach: [
'Angular single-page application with TypeScript, HTML and SCSS/CSS, Ngrx Store for the application state and Ngx Pipes for data handling.',
'C# and .NET backend with a RESTful API and Entity Framework for database access.',
'Test-driven development, with unit and integration tests in xUnit.',
'Azure SQL Server administration, with Okta and OAuth 2 for authentication and authorisation.',
'CI/CD pipelines and project management in Azure DevOps, working in Scrum.',
],
outcome: [
'Development, QA and production environments were migrated from Azure App Services onto a Kubernetes cluster on Azure AKS.',
'The evaluations required for BaFin run across the full policy set.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'Ngrx Store',
'Ngx Pipes',
'C#',
'.NET',
'Entity Framework',
'xUnit',
'Azure SQL Server',
'Okta',
'OAuth 2',
'Azure AKS',
'Azure DevOps',
],
tags: ['insurance', 'fullstack', 'Kubernetes', 'Azure', 'regulation'],
linkLabel: 'Read the case: HDI Specialty',
teaser:
'Fullstack and DevOps on exposure-management software. Dev, QA and production moved from Azure App Services onto Azure AKS.',
},
devdays: {
id: 'devdays',
client: 'Magdeburger Dev Days',
role: 'Fullstack developer',
period: 'August 2020 March 2022',
domain: 'Events and web',
headline: 'A developer event online: visitor site, planning tools and a streaming platform',
summary:
'An annual developer event needs both halves — the public homepage visitors see, and the internal tools the programme is planned with. For the 2021 edition I built a Kubernetes-based streaming platform so the event could run online.',
responsibility:
"Fullstack development on the event's public site and its internal planning tools, and the streaming platform for the online edition in 2021.",
situation:
'Magdeburger Dev Days is an annual developer event. The public homepage carries it towards visitors; the internal planning tools carry the programme behind it. In 2021 the event could not take place on site, so it needed a platform to run on instead.',
approach: [
'Angular front end with TypeScript, HTML and SCSS/CSS, Ngrx Store for the application state, Ngx Pipes for data handling and Angular Material for the components.',
'Node.js supporting the development environment.',
'C# and .NET Core backend, with SQL and T-SQL data work on Microsoft SQL Server 2019.',
'A Kubernetes-based streaming platform for the 2021 edition, so the event could be held online.',
'CI/CD pipelines for automated tests and deployments, working in Scrum.',
],
outcome: [
'The 2021 edition ran online, on the streaming platform.',
'The public homepage and the internal planning tools were developed and maintained together.',
],
metrics: [],
stack: [
'Angular',
'TypeScript',
'HTML',
'SCSS',
'Ngrx Store',
'Ngx Pipes',
'Angular Material',
'Node.js',
'C#',
'.NET Core',
'SQL',
'T-SQL',
'Microsoft SQL Server 2019',
'Kubernetes',
'GitLab CI/CD',
],
tags: ['events', 'fullstack', 'Kubernetes', 'streaming'],
linkLabel: 'Read the case: Magdeburger Dev Days',
teaser:
'Visitor site and internal planning tools for an annual developer event, plus a Kubernetes streaming platform for 2021.',
},
};

View File

@@ -0,0 +1,108 @@
import { type HomePageCopy } from '../content.contracts';
export const HOME_EN: HomePageCopy = {
routeId: 'home',
title: 'Home | Antonio Ledebuhr',
description:
'Servers, networks and infrastructure first, then platforms, software and AI workflows. One technical contact from the first briefing through to operation.',
hero: {
headline: 'You say what the business needs. I take care of the systems it runs on.',
proof:
'At Fahrschule Bannier I have run the whole IT since roughly 2018. At Elbwärme the infrastructure, Microsoft 365 and the website have been mine since November 2025.',
playfulLine: 'Coffee, cables, code',
body: [
'You do not need the technical vocabulary. Describe what has to keep working, what keeps breaking, or what you want to be able to do next year.',
'From there it is my job: the servers and the network first, then the platform they run on, then the software on top — and an AI workflow only where it earns its place.',
],
},
serviceAreasHeading: 'Where I can help',
serviceAreas: [
{
id: 'infrastructure-network',
title: 'Servers and networks',
body: 'Servers, virtualisation and the network your company works on every day — on your own hardware where that is the right answer.',
routeId: 'services',
fragment: 'infrastructure-network',
},
{
id: 'platform-operations',
title: 'Platforms and operations',
body: 'Microsoft 365, containers and Kubernetes, pipelines, and the arrangement that keeps them running after go-live.',
routeId: 'services',
fragment: 'platform-operations',
},
{
id: 'software-data',
title: 'Software and data',
body: 'Product software from the domain through the API to the screen, plus SQL and T-SQL data work and migrations.',
routeId: 'services',
fragment: 'software-data',
},
{
id: 'ai-workflows',
title: 'AI workflows',
body: 'Two tools are delivered and in daily use at Rösterei Tangermünde. Everything beyond those two is scoped and reviewed for your case first.',
routeId: 'services',
fragment: 'ai-workflows',
},
],
processHeading: 'How the work runs',
process: [
{
id: 'describe',
title: 'You describe the situation',
body: 'In your own words, on a call or in a short briefing. Technical vocabulary is not the entry ticket.',
},
{
id: 'diagnose',
title: 'I look at it and say what I think',
body: 'Which area it belongs in, what it will realistically take, and which part of it is not mine to do.',
},
{
id: 'implement',
title: 'The work gets done',
body: 'Infrastructure, platform, software or a clearly bounded AI tool — with the same person on it who took the briefing.',
},
{
id: 'operate',
title: 'Operation or handover',
body: 'Either I keep running it, or your team takes it over from structures that are written down. Which of the two it is gets agreed in advance.',
},
],
proofCaseId: 'roesterei',
proofHeading: 'From the network to the shop, inside one company',
proofNote: 'I hold an economic participation in Rösterei Tangermünde.',
featuredCaseIds: ['bannier', 'elbwaerme', 'hdi', 'innofocus'],
sections: [
{
id: 'areas',
headline: 'Four areas, in that order',
body: [
'The order is not decoration: the infrastructure carries the platform, the platform carries the software, and an AI workflow only makes sense once the three below it are steady.',
'Work that falls outside those four areas gets named in the briefing rather than quietly sold.',
],
},
{
id: 'team',
headline: 'Who you are actually talking to',
body: [
'I am the technical contact, from the first call through to operation. Nobody hands you on to somebody you have not met.',
'We are two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and handles the commercial and organisational side; he is not a second engineer or developer. Beyond the two of us sits a curated network of specialists I trust, so a brief that suits one of them better gets said out loud and is either passed on or delivered together.',
],
},
{
id: 'proof',
headline: 'Why the roastery is the proof case',
body: [
'Rösterei Tangermünde is the one company where the whole chain is mine: network and server structures, the everyday services, the online shop, and two AI tools in internal use.',
'The other cases each carry one area. They are listed with their period and their scope, and nothing more than that.',
],
},
],
ctas: [
{ label: 'Start a project briefing', routeId: 'contact' },
{ label: 'See the four areas', routeId: 'services' },
{ label: 'Servers and networks', routeId: 'services', fragment: 'infrastructure-network' },
{ label: 'The Rösterei Tangermünde case', routeId: 'projects', fragment: 'roesterei' },
],
};

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:
'Six public cases: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty and the Magdeburger Dev Days. The full curriculum vitae is available as a download.',
hero: {
headline: 'Six public cases, deliberately chosen',
proof:
'The site shows a curated selection. The full curriculum vitae is available as a PDF download.',
},
sections: [
{
id: 'selection',
headline: 'Why only these six',
body: [
'Six cases are public: Innofocus / reifen.com, Rösterei Tangermünde, Fahrschule Bannier, Elbwärme, HDI Specialty and the Magdeburger Dev Days. They are the ones I can describe in full, with a client name attached, and together they cover the four areas of work.',
],
},
{
id: 'reading',
headline: 'What each case documents',
body: [
'Every case names the client, the role, the period, the situation, the approach and the outcome. Figures appear only where the project produced them.',
],
},
],
ctas: [
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Contact', routeId: 'contact' },
],
caseLabels: {
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 C# and .NET and in Java with Spring, Angular on the front end. Data work in SQL Server with T-SQL. Containers and clusters with Docker, Kubernetes and Azure AKS. Pipelines in Azure DevOps and GitLab CI/CD, GitOps with Argo CD. Servers, networks and virtualisation on site, plus Microsoft 365 in day-to-day operation.',
],
},
],
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 Angular, TypeScript, SCSS, C#, .NET, Entity Framework, xUnit, Java with Spring WebFlux, Microsoft SQL Server, T-SQL, MariaDB, Node.js, Docker, Kubernetes, Proxmox, Azure AKS, Azure SQL Server, Azure DevOps, GitLab CI/CD, Argo CD, Keycloak, Okta, OAuth 2, Microsoft 365 and Shopify with Liquid and Hydrogen. The remaining entries in the grid show breadth and familiarity, without a public case behind them.',
};
export const ABOUT_EN: PageCopy = {
routeId: 'about',
title: 'About | Antonio Ledebuhr',
description:
'Antonio Ledebuhr, fullstack and DevOps engineer in Tangermünde. Freelance since April 2023, a two-person core team, German native, English at negotiation level.',
hero: {
headline: 'Antonio Ledebuhr, fullstack and DevOps engineer',
proof:
'Based in Tangermünde, freelance since April 2023. German native speaker, English at negotiation level.',
},
sections: [
{
id: 'profile',
headline: 'Professional profile',
body: [
'Professional work on building and operating web applications goes back to 2019. It 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-team',
headline: 'Who you deal with, and who else is involved',
body: [
'I am the technical point of contact, from the first requirements conversation through to production. That has been part of the work from the start, not something added later.',
'The core team is two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and covers the commercial and organisational side; he is not a second engineer or developer.',
'Around us sits a curated network of specialists, so a brief that fits somebody else better is passed on openly or delivered jointly. Fit decides that, not availability.',
'German is my native language. English is at negotiation level.',
],
},
{
id: 'accent',
headline: 'Roastery and torque',
body: [
'The roastery is the personal part of the work, and it is where a good share of my own time goes. I hold an economic participation in Rösterei Tangermünde.',
],
},
],
ctas: [
{ label: 'Projects', routeId: 'projects' },
{ label: 'For recruiters', routeId: 'pitch' },
{ label: 'Contact', routeId: 'contact' },
],
};
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: [
'If the briefing is more structure than you need, write to info@antoniolede.de and describe it in your own words. I answer myself; there is no sales layer in between.',
],
},
],
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 — pick “Not sure yet” if you would rather describe it below',
required: false,
options: [
{ value: 'unsure', label: 'Not sure yet' },
{ value: 'infrastructure-network', label: 'Servers and networks' },
{ value: 'platform-operations', label: 'Platform and operations' },
{ value: 'software-data', label: 'Software and data' },
{ value: 'ai-workflows', label: 'AI workflows' },
{ value: 'other', label: 'Something else' },
],
},
{
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',
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 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,135 @@
import { SITE_CONFIG } from '../site-config';
import { type PitchPageCopy } from '../content.contracts';
export const PITCH_EN: PitchPageCopy = {
routeId: 'pitch',
title: 'Profile | Antonio Ledebuhr',
description:
'Fullstack and DevOps engineer in Tangermünde: building and operating web applications, direct customer contact and team responsibility. Freelance since April 2023.',
hero: {
headline: 'Fullstack and DevOps engineer in Tangermünde',
proof:
'Building and operating web applications, from the requirement through to production. Freelance since April 2023.',
playfulLine: 'Full-stack, full throttle',
},
profile: [
'Building and operating web applications is the core of the work, from the data model through to production.',
'Fullstack development, DevOps, direct customer contact from the first requirements conversation through to production, and team responsibility.',
'Freelance since April 2023, based in Tangermünde.',
'Java with Spring is where I work fastest; alongside it C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, GitLab CI/CD and Azure DevOps.',
'Today the technical point of contact in a two-person core team between Tangermünde and Magdeburg; the commercial side sits with my colleague, the technology with me, and a curated network of specialists sits around the two of us.',
],
metrics: [
{
id: 'innofocus-migration-runtime',
value: '8 h → about 90 min',
label: 'data-migration run time at Innofocus / reifen.com',
note: 'at a larger feature scope and higher data quality',
caseId: 'innofocus',
},
],
timelineHeading: 'Stations',
timeline: [
{
id: 'bannier',
period: 'since roughly 2018',
role: 'IT support and operation at Fahrschule Bannier',
body: 'The whole IT of a driving school, including support and operation of the specialist software it uses. That software comes from its vendor; I did not develop it.',
},
{
id: 'devdays',
period: 'August 2020 March 2022',
role: 'Fullstack developer, Magdeburger Dev Days',
body: 'Visitor site and internal planning tools for an annual developer event, plus a Kubernetes-based streaming platform for the 2021 edition.',
},
{
id: 'self-employed',
period: 'since December 2021',
role: 'running my own business',
body: 'Own responsibility for the brief, the technology and operations, including invoicing and client relationships.',
},
{
id: 'freelance',
period: 'since April 2023',
role: 'freelance',
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements conversation through to production.',
},
{
id: 'hdi',
period: 'May 2023 January 2024',
role: 'Senior fullstack / DevOps at HDI Specialty',
body: 'Fullstack and DevOps ownership in a regulated insurance setting, including the move onto Azure AKS.',
},
{
id: 'roesterei',
period: 'since October 2023',
role: 'Full technical responsibility at Rösterei Tangermünde',
body: "One contact for the company's technology as a whole. The economic participation stays disclosed.",
},
{
id: 'innofocus',
period: 'since September 2025',
role: 'Senior backend and database engineer at Innofocus / reifen.com',
body: 'Sole ownership of the migration, as the only freelancer in direct contact with the end customer.',
},
{
id: 'elbwaerme',
period: 'since November 2025',
role: 'Infrastructure and IT support at Elbwärme',
body: 'Infrastructure, Microsoft 365 and the website; the responsibility has been mine since November 2025.',
},
],
stackHeading: 'Core stack',
coreStack: [
{
id: 'java',
title: 'Java and Spring',
body: 'Home stack: Java with Spring Boot, Spring Data and tests.',
},
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
{
id: 'angular',
title: 'Angular and TypeScript',
body: 'Angular SPAs with RxJS and SCSS.',
},
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
{
id: 'containers',
title: 'Docker and Kubernetes',
body: 'Containers and clusters on-premise and in the cloud.',
},
{
id: 'azure',
title: 'Azure including AKS',
body: 'Azure AKS, Azure DevOps and related cloud services.',
},
{
id: 'cicd',
title: 'GitLab CI/CD and Azure DevOps',
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
sections: [
{
id: 'profile',
headline: 'In thirty seconds',
body: [
'I work as a fullstack and DevOps engineer out of Tangermünde, freelance since April 2023. The CV as a PDF lists every station in full.',
],
},
{
id: 'featured-cases',
headline: 'Selected cases',
body: [
'The public cases cover ERP and eCommerce, company IT and infrastructure, insurance and an annual developer event. Each one is written out in full on the projects page.',
],
},
],
ctas: [
{ label: 'Selected projects', routeId: 'projects' },
{ label: 'View the stack', routeId: 'stack' },
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
],
};

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