Compare commits
49 Commits
f9b4054571
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
| 4389f3dd3c | |||
| 334b30145e | |||
| 0e1e48d876 | |||
| 50851f4d26 | |||
| 0b74be69be | |||
| 7619205d91 | |||
| 8974b4c6fb | |||
| 9bfb1dc71c | |||
| bb9912daef | |||
| a9ad7eb1a7 | |||
| 55d012cc10 | |||
| 2635d0c639 | |||
| b95dbd8e1a | |||
| e63b4edade | |||
| b8b9e57d06 | |||
| 19d00da9bb | |||
| 35a575dba9 | |||
| 255f0eace6 | |||
| 359e887a7a | |||
| 5921fc5ede | |||
| 162bd790da | |||
| 646b39b200 | |||
| 86d6dd085d | |||
| b15dfd7801 | |||
| 575ba06bb9 | |||
| 1ff26be61b | |||
| 1de146c650 | |||
| 1d2965a7ff | |||
| 271df12334 | |||
| 34367181d2 | |||
| 46c86447d1 | |||
| 2ccac6b28a | |||
| da38cc3624 | |||
| 763bf435c9 | |||
| 6c1572bf3c | |||
| cfc7580a8f | |||
| 7813499ee3 | |||
| d954361724 | |||
| 9ea2885c7b | |||
| 42e9f01253 | |||
| 1020ac4c68 | |||
| 95193cf99e | |||
| 6b170c6864 | |||
| 55a63047cd | |||
| e87dd76626 | |||
| fb917d56e6 | |||
| 47920509d3 | |||
| 010520f722 | |||
| 66a8bacbe8 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -41,3 +41,9 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Browser and audit artifacts
|
||||
/test-results
|
||||
/playwright-report
|
||||
/blob-report
|
||||
/.lighthouseci
|
||||
|
||||
14
.prettierignore
Normal file
14
.prettierignore
Normal 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
161
AGENTS.md
Normal 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.
|
||||
87
README.md
87
README.md
@@ -1,59 +1,68 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
ng generate --help
|
||||
`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.
|
||||
|
||||
## 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
|
||||
ng build
|
||||
```
|
||||
## Legal pages
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
12
angular.json
12
angular.json
@@ -23,15 +23,21 @@
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src"]
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
},
|
||||
{
|
||||
"glob": "CV.pdf",
|
||||
"input": ".",
|
||||
"output": "cv"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"server": "src/main.server.ts",
|
||||
"outputMode": "server",
|
||||
"ssr": {
|
||||
|
||||
53
e2e/a11y.e2e.ts
Normal file
53
e2e/a11y.e2e.ts
Normal 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
41
e2e/contact.e2e.ts
Normal 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
79
e2e/fragments.e2e.ts
Normal 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
97
e2e/helpers.ts
Normal 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
109
e2e/keyboard.e2e.ts
Normal 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
81
e2e/layout.e2e.ts
Normal 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
140
e2e/seo.e2e.ts
Normal 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
155
e2e/systems-map.e2e.ts
Normal 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
158
e2e/terminal.e2e.ts
Normal 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
62
eslint.config.js
Normal 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
34
lighthouserc.json
Normal 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
4760
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -7,7 +7,18 @@
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"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": {
|
||||
"printWidth": 100,
|
||||
@@ -40,10 +51,21 @@
|
||||
"@angular/build": "^21.0.5",
|
||||
"@angular/cli": "^21.0.5",
|
||||
"@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/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",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
40
playwright.config.ts
Normal file
40
playwright.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
49
public/llms.txt
Normal file
49
public/llms.txt
Normal 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.
|
||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://antoniolede.de/sitemap.xml
|
||||
111
public/sitemap.xml
Normal file
111
public/sitemap.xml
Normal 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>
|
||||
123
scripts/add-cv-pitch-link.mjs
Normal file
123
scripts/add-cv-pitch-link.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PDFDocument, PDFName, PDFString, rgb, StandardFonts } from 'pdf-lib';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CV_PATH = join(ROOT, 'CV.pdf');
|
||||
const PITCH_URI = 'https://antoniolede.de/pitch';
|
||||
const VISIBLE_TEXT = 'antoniolede.de/pitch';
|
||||
const TEXT_X = 59.6;
|
||||
const TEXT_Y = 215.4;
|
||||
const FONT_SIZE = 10.5;
|
||||
const PADDING = 2;
|
||||
const ORIGINAL_TITLE = 'CV';
|
||||
const ORIGINAL_CREATOR = 'Pages';
|
||||
// Producer from the unmodified Pages/Quartz export of CV.pdf.
|
||||
const ORIGINAL_PRODUCER = 'macOS Version 26.5.2 (Build 25F84) Quartz PDFContext';
|
||||
|
||||
function asString(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value.decodeText === 'function') {
|
||||
return value.decodeText();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function annotationUri(annotation) {
|
||||
const action = annotation.lookup(PDFName.of('A'));
|
||||
if (!action || typeof action.lookup !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const uri = action.lookup(PDFName.of('URI'));
|
||||
return asString(uri);
|
||||
}
|
||||
|
||||
function pageHasPitchLink(page) {
|
||||
const annots = page.node.lookup(PDFName.of('Annots'));
|
||||
if (!annots || typeof annots.size !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < annots.size(); index += 1) {
|
||||
const annotation = annots.lookup(index);
|
||||
if (annotationUri(annotation) === PITCH_URI) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOriginalMetadata(pdfDoc) {
|
||||
return (
|
||||
pdfDoc.getTitle() === ORIGINAL_TITLE &&
|
||||
pdfDoc.getCreator() === ORIGINAL_CREATOR &&
|
||||
pdfDoc.getProducer() === ORIGINAL_PRODUCER
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = readFileSync(CV_PATH);
|
||||
const pdfDoc = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||
const page = pdfDoc.getPage(0);
|
||||
const needsLink = !pageHasPitchLink(page);
|
||||
const needsMetadata = !hasOriginalMetadata(pdfDoc);
|
||||
|
||||
if (!needsLink && !needsMetadata) {
|
||||
console.log('Pitch link and original metadata already present; leaving CV.pdf unchanged.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (needsLink) {
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const textWidth = font.widthOfTextAtSize(VISIBLE_TEXT, FONT_SIZE);
|
||||
|
||||
page.drawText(VISIBLE_TEXT, {
|
||||
x: TEXT_X,
|
||||
y: TEXT_Y,
|
||||
size: FONT_SIZE,
|
||||
font,
|
||||
color: rgb(54 / 255, 125 / 255, 162 / 255),
|
||||
});
|
||||
|
||||
const link = pdfDoc.context.register(
|
||||
pdfDoc.context.obj({
|
||||
Type: 'Annot',
|
||||
Subtype: 'Link',
|
||||
Rect: [
|
||||
TEXT_X - PADDING,
|
||||
TEXT_Y - PADDING,
|
||||
TEXT_X + textWidth + PADDING,
|
||||
TEXT_Y + FONT_SIZE + PADDING,
|
||||
],
|
||||
Border: [0, 0, 0],
|
||||
A: {
|
||||
S: 'URI',
|
||||
URI: PDFString.of(PITCH_URI),
|
||||
},
|
||||
}),
|
||||
);
|
||||
page.node.addAnnot(link);
|
||||
}
|
||||
|
||||
if (needsMetadata) {
|
||||
pdfDoc.setTitle(ORIGINAL_TITLE);
|
||||
pdfDoc.setCreator(ORIGINAL_CREATOR);
|
||||
pdfDoc.setProducer(ORIGINAL_PRODUCER);
|
||||
}
|
||||
|
||||
const saved = await pdfDoc.save({ useObjectStreams: false });
|
||||
writeFileSync(CV_PATH, saved);
|
||||
|
||||
if (needsLink && needsMetadata) {
|
||||
console.log('Added the pitch link and restored the original CV.pdf metadata.');
|
||||
} else if (needsLink) {
|
||||
console.log('Added visible pitch URL and link annotation to page 1 of CV.pdf.');
|
||||
} else {
|
||||
console.log('Restored the original Title, Creator and Producer on CV.pdf.');
|
||||
}
|
||||
14
src/_breakpoints.scss
Normal file
14
src/_breakpoints.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import { appConfig } from './app.config';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideServerRendering(withRoutes(serverRoutes))
|
||||
]
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
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 = {
|
||||
providers: [
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,12 +1,73 @@
|
||||
<app-dot-background></app-dot-background>
|
||||
<div>
|
||||
<header>
|
||||
|
||||
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
|
||||
<app-dot-background aria-hidden="true"></app-dot-background>
|
||||
<div class="site" [class.nav-collapsed]="!navOpen()">
|
||||
<header class="site-header">
|
||||
<div class="content-container site-header-inner glass-surface">
|
||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||
{{ siteConfig.personName }}
|
||||
</a>
|
||||
<div class="site-toolbar cluster">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
[attr.aria-expanded]="navOpen()"
|
||||
aria-controls="primary-nav"
|
||||
[attr.aria-label]="menuLabel()"
|
||||
(click)="toggleNav()"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<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
|
||||
class="language-switch"
|
||||
[routerLink]="navigation.alternateLocaleLink()"
|
||||
[attr.hreflang]="otherHtmlLang()"
|
||||
[attr.aria-label]="shell().languageSwitch"
|
||||
>
|
||||
{{ shell().otherLocaleName }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="router">
|
||||
<router-outlet></router-outlet>
|
||||
<main id="main-content" class="site-main" tabindex="-1">
|
||||
<router-outlet />
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="content-container site-footer-inner cluster">
|
||||
<p>{{ siteConfig.personName }}</p>
|
||||
<a [href]="'mailto:' + siteConfig.contactEmail">{{ siteConfig.contactEmail }}</a>
|
||||
<nav [attr.aria-label]="shell().footerNavLabel">
|
||||
<ul class="cluster footer-nav">
|
||||
@for (item of navigation.footerNav(); track item.routeId) {
|
||||
<li>
|
||||
<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>
|
||||
|
||||
22
src/app/app.routes.server.spec.ts
Normal file
22
src/app/app.routes.server.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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[] = [
|
||||
...prerenderableServerPaths().map((path): ServerRoute => ({
|
||||
path,
|
||||
renderMode: RenderMode.Prerender,
|
||||
})),
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
}
|
||||
renderMode: RenderMode.Server,
|
||||
status: 404,
|
||||
},
|
||||
];
|
||||
|
||||
90
src/app/app.routes.spec.ts
Normal file
90
src/app/app.routes.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,58 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import {Skills} from './components/pages/skills/skills';
|
||||
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';
|
||||
|
||||
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: '', component: Skills}
|
||||
{
|
||||
path: LOCALE_PREFIX.en,
|
||||
children: localeRoutes('en'),
|
||||
},
|
||||
...localeRoutes('de'),
|
||||
];
|
||||
|
||||
187
src/app/app.scss
187
src/app/app.scss
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,168 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
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', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
mockViewport(true);
|
||||
|
||||
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 app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
await fixture.whenStable();
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
it('should render an accessible application shell', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
106
src/app/app.ts
106
src/app/app.ts
@@ -1,13 +1,109 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { DOCUMENT, ViewportScroller } from '@angular/common';
|
||||
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({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, DotBackground],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: #0a0a0f;
|
||||
background: var(--color-surface);
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
width: 100lvw;
|
||||
height: 100lvh;
|
||||
}
|
||||
|
||||
@@ -1,23 +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', () => {
|
||||
let component: DotBackground;
|
||||
let fixture: ComponentFixture<DotBackground>;
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, 'matchMedia');
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DotBackground);
|
||||
component = fixture.componentInstance;
|
||||
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('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
it('does not throw when destroyed after browser initialization', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||
|
||||
const 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core';
|
||||
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({
|
||||
@@ -6,68 +20,195 @@ import {Dot} from '../../models/dot';
|
||||
imports: [],
|
||||
templateUrl: './dot-background.html',
|
||||
styleUrl: './dot-background.scss',
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
})
|
||||
export class DotBackground implements OnDestroy {
|
||||
export class DotBackground {
|
||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
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 initialized = false;
|
||||
private resizeFrameId = 0;
|
||||
private loopActive = false;
|
||||
private tornDown = false;
|
||||
private readonly teardowns: Array<() => void> = [];
|
||||
|
||||
private readonly DOT_COUNT = 12;
|
||||
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 lastWidth = 0;
|
||||
private lastHeight = 0;
|
||||
private readonly RESIZE_THRESHOLD = 100; // ignore small height changes (browser chrome)
|
||||
private ballSpawnId = 0;
|
||||
private ballSpawnNextColor = 0;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
|
||||
constructor(private ngZone: NgZone) {
|
||||
afterNextRender(() => {
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (!this.initialized) return;
|
||||
|
||||
cancelAnimationFrame(this.animationId);
|
||||
window.removeEventListener('resize', this.resize);
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
private view(): Window | null {
|
||||
return this.document.defaultView;
|
||||
}
|
||||
|
||||
private init() {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
this.resize();
|
||||
this.initDots();
|
||||
|
||||
window.addEventListener('resize', this.resize);
|
||||
window.addEventListener('mousemove', this.onMouseMove);
|
||||
|
||||
this.initialized = true;
|
||||
this.ngZone.runOutsideAngular(() => this.animate());
|
||||
}
|
||||
|
||||
private resize = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
const widthChanged = width !== this.lastWidth;
|
||||
const heightDiff = Math.abs(height - this.lastHeight);
|
||||
const significantHeightChange = heightDiff > this.RESIZE_THRESHOLD;
|
||||
|
||||
// Only resize on width change or significant height change (orientation flip, not browser chrome)
|
||||
if (!widthChanged && !significantHeightChange && this.lastWidth !== 0) {
|
||||
private init(): void {
|
||||
if (this.tornDown || !this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastWidth = width;
|
||||
this.lastHeight = height;
|
||||
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;
|
||||
|
||||
@@ -75,38 +216,115 @@ export class DotBackground implements OnDestroy {
|
||||
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 = (e: MouseEvent) => {
|
||||
this.mouse.x = e.clientX;
|
||||
this.mouse.y = e.clientY;
|
||||
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 initDots() {
|
||||
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;
|
||||
for (let i = 0; i < this.DOT_COUNT; i++) {
|
||||
this.dots.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
radius: Math.random() * 60 + 40,
|
||||
color: this.COLORS[i % this.COLORS.length],
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
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;
|
||||
this.ctx.clearRect(0, 0, width, height);
|
||||
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 < this.MOUSE_RADIUS) {
|
||||
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;
|
||||
@@ -119,29 +337,32 @@ export class DotBackground implements OnDestroy {
|
||||
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;
|
||||
}
|
||||
|
||||
// Bounce off edges (accounting for radius)
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// Clamp to bounds
|
||||
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 = this.ctx.createRadialGradient(
|
||||
dot.x, dot.y, 0,
|
||||
dot.x, dot.y, dot.radius
|
||||
);
|
||||
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');
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<div class="card">
|
||||
<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
|
||||
|
||||
@@ -1,44 +1,23 @@
|
||||
// skill-card.component.scss
|
||||
.card {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(99, 102, 241, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
@@ -51,13 +30,17 @@
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
filter: grayscale(100%) brightness(0.8);
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
filter var(--duration-fast) var(--ease-standard),
|
||||
transform var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card .icons a:hover img {
|
||||
filter: none;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ describe('SkillCard', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SkillCard]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [SkillCard],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SkillCard);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="grid">
|
||||
@for (cat of categories; track cat.category) {
|
||||
@for (cat of categories(); track cat.category) {
|
||||
<app-skill-card
|
||||
[title]="cat.title"
|
||||
[category]="cat.category"
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ describe('SkillsGrid', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SkillsGrid]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [SkillsGrid],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SkillsGrid);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import {Component} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import { SkillCategory } from '../../../../models/skill-category';
|
||||
import { SkillCard } from '../skill-card/skill-card';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skills-grid',
|
||||
imports: [
|
||||
SkillCard
|
||||
],
|
||||
templateUrl: './skills-grid.html',
|
||||
styleUrl: './skills-grid.scss',
|
||||
})
|
||||
export class SkillsGrid {
|
||||
categories: SkillCategory[] = [
|
||||
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: '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/' },
|
||||
@@ -33,7 +28,11 @@ export class SkillsGrid {
|
||||
category: 'db',
|
||||
gridArea: 'db',
|
||||
skills: [
|
||||
{name: 'SQL Server', icon: 'microsoftsqlserver', url: 'https://www.microsoft.com/en-us/sql-server'},
|
||||
{
|
||||
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/' },
|
||||
@@ -47,7 +46,11 @@ export class SkillsGrid {
|
||||
{ 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 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/' },
|
||||
@@ -79,7 +82,11 @@ export class SkillsGrid {
|
||||
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/'},
|
||||
{
|
||||
name: 'Hyper-V',
|
||||
icon: 'hyperv',
|
||||
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -89,8 +96,34 @@ export class SkillsGrid {
|
||||
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'},
|
||||
{
|
||||
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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="main">
|
||||
<h1>Antonio Ledebuhr</h1>
|
||||
<p>Software Engineering & DevOps</p>
|
||||
<app-skills-grid></app-skills-grid>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 0.5rem 0 3rem;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Skills } from './skills';
|
||||
|
||||
describe('Skills', () => {
|
||||
let component: Skills;
|
||||
let fixture: ComponentFixture<Skills>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Skills]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Skills);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {SkillsGrid} from './skills-grid/skills-grid';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skills',
|
||||
imports: [
|
||||
SkillsGrid
|
||||
],
|
||||
templateUrl: './skills.html',
|
||||
styleUrl: './skills.scss',
|
||||
})
|
||||
export class Skills {
|
||||
|
||||
}
|
||||
10
src/app/core/commands/command-ids.ts
Normal file
10
src/app/core/commands/command-ids.ts
Normal 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',
|
||||
];
|
||||
154
src/app/core/content/content-claims.spec.ts
Normal file
154
src/app/core/content/content-claims.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
117
src/app/core/content/content-completeness.spec.ts
Normal file
117
src/app/core/content/content-completeness.spec.ts
Normal 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([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
72
src/app/core/content/content-exclusions.spec.ts
Normal file
72
src/app/core/content/content-exclusions.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
87
src/app/core/content/content-parity.spec.ts
Normal file
87
src/app/core/content/content-parity.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS } from '../routing/route-ids';
|
||||
import { CASE_STUDY_IDS } from './content.contracts';
|
||||
import { SITE_CONTENT_DATA } from './site-content';
|
||||
|
||||
const STRUCTURAL_KEYS = new Set([
|
||||
'id',
|
||||
'status',
|
||||
'control',
|
||||
'required',
|
||||
'routeId',
|
||||
'caseId',
|
||||
'referenceCaseId',
|
||||
'external',
|
||||
]);
|
||||
|
||||
function assertParity(left: unknown, right: unknown, path: string): void {
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
expect(Array.isArray(left), path).toBe(true);
|
||||
expect(Array.isArray(right), path).toBe(true);
|
||||
expect((left as unknown[]).length, path).toBe((right as unknown[]).length);
|
||||
(left as unknown[]).forEach((item, index) =>
|
||||
assertParity(item, (right as unknown[])[index], `${path}[${index}]`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (left && right && typeof left === 'object' && typeof right === 'object') {
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
expect(leftKeys, path).toEqual(rightKeys);
|
||||
|
||||
for (const key of leftKeys) {
|
||||
const nextPath = `${path}.${key}`;
|
||||
const leftValue = (left as Record<string, unknown>)[key];
|
||||
const rightValue = (right as Record<string, unknown>)[key];
|
||||
if (STRUCTURAL_KEYS.has(key)) {
|
||||
expect(leftValue, nextPath).toEqual(rightValue);
|
||||
} else {
|
||||
assertParity(leftValue, rightValue, nextPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof left === 'string' &&
|
||||
typeof right === 'string' &&
|
||||
(/featuredCaseIds\[\d+\]$/.test(path) || /options\[\d+\]\.value$/.test(path))
|
||||
) {
|
||||
expect(left, path).toBe(right);
|
||||
}
|
||||
}
|
||||
|
||||
describe('content parity', () => {
|
||||
it('keeps the German and English trees structurally aligned', () => {
|
||||
assertParity(SITE_CONTENT_DATA.de, SITE_CONTENT_DATA.en, 'site');
|
||||
});
|
||||
|
||||
it('uses different long-form copy in German and English', () => {
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
const german = SITE_CONTENT_DATA.de.pages[routeId];
|
||||
const english = SITE_CONTENT_DATA.en.pages[routeId];
|
||||
|
||||
expect(german.title, routeId).not.toBe(english.title);
|
||||
expect(german.description, routeId).not.toBe(english.description);
|
||||
expect(german.hero.headline, routeId).not.toBe(english.hero.headline);
|
||||
|
||||
german.sections.forEach((section, index) => {
|
||||
const other = english.sections[index];
|
||||
expect(section.body?.join('\n'), `${routeId}.${section.id}`).not.toBe(
|
||||
other?.body?.join('\n'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const caseId of CASE_STUDY_IDS) {
|
||||
const german = SITE_CONTENT_DATA.de.cases[caseId];
|
||||
const english = SITE_CONTENT_DATA.en.cases[caseId];
|
||||
expect(german.summary, caseId).not.toBe(english.summary);
|
||||
expect(german.situation, caseId).not.toBe(english.situation);
|
||||
expect(german.linkLabel, caseId).not.toBe(english.linkLabel);
|
||||
}
|
||||
|
||||
expect(APP_LOCALES).toEqual(['de', 'en']);
|
||||
});
|
||||
});
|
||||
144
src/app/core/content/content-service-led.spec.ts
Normal file
144
src/app/core/content/content-service-led.spec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
254
src/app/core/content/content.contracts.ts
Normal file
254
src/app/core/content/content.contracts.ts
Normal 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;
|
||||
}
|
||||
66
src/app/core/content/content.service.ts
Normal file
66
src/app/core/content/content.service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
5
src/app/core/content/content.token.ts
Normal file
5
src/app/core/content/content.token.ts
Normal 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');
|
||||
263
src/app/core/content/de/cases.ts
Normal file
263
src/app/core/content/de/cases.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
109
src/app/core/content/de/home.ts
Normal file
109
src/app/core/content/de/home.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
375
src/app/core/content/de/pages.ts
Normal file
375
src/app/core/content/de/pages.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
135
src/app/core/content/de/pitch.ts
Normal file
135
src/app/core/content/de/pitch.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type PitchPageCopy } from '../content.contracts';
|
||||
|
||||
export const PITCH_DE: PitchPageCopy = {
|
||||
routeId: 'pitch',
|
||||
title: 'Profil | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack- und DevOps-Ingenieur in Tangermünde: Webanwendungen bauen und betreiben, direkter Kundenkontakt und Teamverantwortung. Freiberuflich seit 04/2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack- und DevOps-Ingenieur in Tangermünde',
|
||||
proof:
|
||||
'Bau und Betrieb von Webanwendungen, von der Anforderung bis in den Produktivbetrieb. Freiberuflich seit 04/2023.',
|
||||
playfulLine: 'IT mit Drehmoment',
|
||||
},
|
||||
profile: [
|
||||
'Bau und Betrieb von Webanwendungen als beruflicher Schwerpunkt, vom Datenmodell bis in den Produktivbetrieb.',
|
||||
'Fullstack-Entwicklung, DevOps, direkter Kundenkontakt vom ersten Anforderungsworkshop bis in den Produktivbetrieb sowie Personal- und Teamverantwortung.',
|
||||
'Freiberuflich seit 04/2023, mit Sitz in Tangermünde.',
|
||||
'Heimspiel Java mit Spring; dazu C# und .NET, Angular, SQL, Docker und Kubernetes, Azure einschließlich AKS sowie GitLab CI/CD und Azure DevOps.',
|
||||
'Heute technischer Ansprechpartner in einem Zweierteam zwischen Tangermünde und Magdeburg; die kaufmännische Seite liegt beim Kollegen, die Technik bei mir. Dazu ein ausgesuchtes Fachnetzwerk.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → ca. 90 min',
|
||||
label: 'Laufzeit der Datenmigration bei Innofocus / reifen.com',
|
||||
note: 'bei größerem Funktionsumfang und höherer Datenqualität',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
timelineHeading: 'Stationen',
|
||||
timeline: [
|
||||
{
|
||||
id: 'bannier',
|
||||
period: 'seit etwa 2018',
|
||||
role: 'IT-Betreuung und Betrieb bei der Fahrschule Bannier',
|
||||
body: 'Die gesamte IT einer Fahrschule, einschließlich Betreuung und Betrieb der eingesetzten Fachsoftware. Diese Software stammt vom Hersteller; entwickelt habe ich sie nicht.',
|
||||
},
|
||||
{
|
||||
id: 'devdays',
|
||||
period: '08/2020 – 03/2022',
|
||||
role: 'Fullstack-Entwickler, Magdeburger Dev Days',
|
||||
body: 'Besucherseite und interne Planungswerkzeuge eines jährlichen Entwicklerevents, dazu eine Streamingplattform auf Kubernetes für die Ausgabe 2021.',
|
||||
},
|
||||
{
|
||||
id: 'self-employed',
|
||||
period: 'seit 12/2021',
|
||||
role: 'unternehmerisch selbständig',
|
||||
body: 'Eigene Verantwortung für Auftrag, Technik und Betrieb, dazu Rechnungsstellung und Kundenbeziehungen.',
|
||||
},
|
||||
{
|
||||
id: 'freelance',
|
||||
period: 'seit 04/2023',
|
||||
role: 'freiberuflich',
|
||||
body: 'Fullstack- und DevOps-Arbeit mit direktem Kundenkontakt, vom Anforderungsworkshop bis in den Produktivbetrieb.',
|
||||
},
|
||||
{
|
||||
id: 'hdi',
|
||||
period: '05/2023 – 01/2024',
|
||||
role: 'Senior Fullstack / DevOps bei HDI Specialty',
|
||||
body: 'Fullstack- und DevOps-Verantwortung in einem regulierten Versicherungsumfeld, einschließlich des Umzugs aller Umgebungen auf Azure AKS.',
|
||||
},
|
||||
{
|
||||
id: 'roesterei',
|
||||
period: 'seit 10/2023',
|
||||
role: 'Gesamtverantwortung für die Technik der Rösterei Tangermünde',
|
||||
body: 'Ein Ansprechpartner für die gesamte Betriebstechnik; die wirtschaftliche Beteiligung bleibt offengelegt.',
|
||||
},
|
||||
{
|
||||
id: 'innofocus',
|
||||
period: 'seit 09/2025',
|
||||
role: 'Senior Backend- und Datenbankingenieur bei Innofocus / reifen.com',
|
||||
body: 'Alleinverantwortung für die Migration, als einziger Freiberufler im direkten Austausch mit dem Endkunden.',
|
||||
},
|
||||
{
|
||||
id: 'elbwaerme',
|
||||
period: 'seit 11/2025',
|
||||
role: 'Infrastruktur und IT-Betreuung bei Elbwärme',
|
||||
body: 'Infrastruktur, Microsoft 365 und Website; die Verantwortung liegt seit 11/2025 bei mir.',
|
||||
},
|
||||
],
|
||||
stackHeading: 'Kernstack',
|
||||
coreStack: [
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java und Spring',
|
||||
body: 'Heimspiel: Java mit Spring Boot, Spring Data und Tests.',
|
||||
},
|
||||
{ id: 'dotnet', title: 'C# und .NET', body: 'ASP.NET Core, Entity Framework und xUnit.' },
|
||||
{
|
||||
id: 'angular',
|
||||
title: 'Angular und TypeScript',
|
||||
body: 'Angular-SPAs, RxJS und SCSS.',
|
||||
},
|
||||
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL und MariaDB.' },
|
||||
{
|
||||
id: 'containers',
|
||||
title: 'Docker und Kubernetes',
|
||||
body: 'Container und Cluster vor Ort und in der Cloud.',
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
title: 'Azure einschließlich AKS',
|
||||
body: 'Azure AKS, Azure DevOps und verwandte Cloud-Dienste.',
|
||||
},
|
||||
{
|
||||
id: 'cicd',
|
||||
title: 'GitLab CI/CD und Azure DevOps',
|
||||
body: 'Pipelines, GitOps mit Argo CD wo es zum Auftrag passt.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In dreißig Sekunden',
|
||||
body: [
|
||||
'Ich arbeite als Fullstack- und DevOps-Ingenieur aus Tangermünde, freiberuflich seit 04/2023. Der Lebenslauf als PDF führt die Stationen vollständig.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Ausgewählte Fälle',
|
||||
body: [
|
||||
'Die öffentlichen Fälle decken eCommerce und ERP, laufende Infrastruktur und Betrieb, Versicherung und eine Veranstaltungsplattform ab. Jeder Fall ist auf der Projektseite vollständig beschrieben.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Ausgewählte Projekte', routeId: 'projects' },
|
||||
{ label: 'Technik-Stack ansehen', routeId: 'stack' },
|
||||
{ label: 'Lebenslauf als PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'E-Mail schreiben', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||
],
|
||||
};
|
||||
416
src/app/core/content/de/services.ts
Normal file
416
src/app/core/content/de/services.ts
Normal 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.',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
46
src/app/core/content/de/site.ts
Normal file
46
src/app/core/content/de/site.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
263
src/app/core/content/en/cases.ts
Normal file
263
src/app/core/content/en/cases.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
108
src/app/core/content/en/home.ts
Normal file
108
src/app/core/content/en/home.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
374
src/app/core/content/en/pages.ts
Normal file
374
src/app/core/content/en/pages.ts
Normal 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' },
|
||||
],
|
||||
};
|
||||
135
src/app/core/content/en/pitch.ts
Normal file
135
src/app/core/content/en/pitch.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { SITE_CONFIG } from '../site-config';
|
||||
import { type PitchPageCopy } from '../content.contracts';
|
||||
|
||||
export const PITCH_EN: PitchPageCopy = {
|
||||
routeId: 'pitch',
|
||||
title: 'Profile | Antonio Ledebuhr',
|
||||
description:
|
||||
'Fullstack and DevOps engineer in Tangermünde: building and operating web applications, direct customer contact and team responsibility. Freelance since April 2023.',
|
||||
hero: {
|
||||
headline: 'Fullstack and DevOps engineer in Tangermünde',
|
||||
proof:
|
||||
'Building and operating web applications, from the requirement through to production. Freelance since April 2023.',
|
||||
playfulLine: 'Full-stack, full throttle',
|
||||
},
|
||||
profile: [
|
||||
'Building and operating web applications is the core of the work, from the data model through to production.',
|
||||
'Fullstack development, DevOps, direct customer contact from the first requirements conversation through to production, and team responsibility.',
|
||||
'Freelance since April 2023, based in Tangermünde.',
|
||||
'Java with Spring is where I work fastest; alongside it C# and .NET, Angular, SQL, Docker and Kubernetes, Azure including AKS, GitLab CI/CD and Azure DevOps.',
|
||||
'Today the technical point of contact in a two-person core team between Tangermünde and Magdeburg; the commercial side sits with my colleague, the technology with me, and a curated network of specialists sits around the two of us.',
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
id: 'innofocus-migration-runtime',
|
||||
value: '8 h → about 90 min',
|
||||
label: 'data-migration run time at Innofocus / reifen.com',
|
||||
note: 'at a larger feature scope and higher data quality',
|
||||
caseId: 'innofocus',
|
||||
},
|
||||
],
|
||||
timelineHeading: 'Stations',
|
||||
timeline: [
|
||||
{
|
||||
id: 'bannier',
|
||||
period: 'since roughly 2018',
|
||||
role: 'IT support and operation at Fahrschule Bannier',
|
||||
body: 'The whole IT of a driving school, including support and operation of the specialist software it uses. That software comes from its vendor; I did not develop it.',
|
||||
},
|
||||
{
|
||||
id: 'devdays',
|
||||
period: 'August 2020 – March 2022',
|
||||
role: 'Fullstack developer, Magdeburger Dev Days',
|
||||
body: 'Visitor site and internal planning tools for an annual developer event, plus a Kubernetes-based streaming platform for the 2021 edition.',
|
||||
},
|
||||
{
|
||||
id: 'self-employed',
|
||||
period: 'since December 2021',
|
||||
role: 'running my own business',
|
||||
body: 'Own responsibility for the brief, the technology and operations, including invoicing and client relationships.',
|
||||
},
|
||||
{
|
||||
id: 'freelance',
|
||||
period: 'since April 2023',
|
||||
role: 'freelance',
|
||||
body: 'Freelance fullstack and DevOps work with direct customer contact, from the requirements conversation through to production.',
|
||||
},
|
||||
{
|
||||
id: 'hdi',
|
||||
period: 'May 2023 – January 2024',
|
||||
role: 'Senior fullstack / DevOps at HDI Specialty',
|
||||
body: 'Fullstack and DevOps ownership in a regulated insurance setting, including the move onto Azure AKS.',
|
||||
},
|
||||
{
|
||||
id: 'roesterei',
|
||||
period: 'since October 2023',
|
||||
role: 'Full technical responsibility at Rösterei Tangermünde',
|
||||
body: "One contact for the company's technology as a whole. The economic participation stays disclosed.",
|
||||
},
|
||||
{
|
||||
id: 'innofocus',
|
||||
period: 'since September 2025',
|
||||
role: 'Senior backend and database engineer at Innofocus / reifen.com',
|
||||
body: 'Sole ownership of the migration, as the only freelancer in direct contact with the end customer.',
|
||||
},
|
||||
{
|
||||
id: 'elbwaerme',
|
||||
period: 'since November 2025',
|
||||
role: 'Infrastructure and IT support at Elbwärme',
|
||||
body: 'Infrastructure, Microsoft 365 and the website; the responsibility has been mine since November 2025.',
|
||||
},
|
||||
],
|
||||
stackHeading: 'Core stack',
|
||||
coreStack: [
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java and Spring',
|
||||
body: 'Home stack: Java with Spring Boot, Spring Data and tests.',
|
||||
},
|
||||
{ id: 'dotnet', title: 'C# and .NET', body: 'ASP.NET Core, Entity Framework and xUnit.' },
|
||||
{
|
||||
id: 'angular',
|
||||
title: 'Angular and TypeScript',
|
||||
body: 'Angular SPAs with RxJS and SCSS.',
|
||||
},
|
||||
{ id: 'sql', title: 'SQL', body: 'Microsoft SQL Server, T-SQL, MySQL and MariaDB.' },
|
||||
{
|
||||
id: 'containers',
|
||||
title: 'Docker and Kubernetes',
|
||||
body: 'Containers and clusters on-premise and in the cloud.',
|
||||
},
|
||||
{
|
||||
id: 'azure',
|
||||
title: 'Azure including AKS',
|
||||
body: 'Azure AKS, Azure DevOps and related cloud services.',
|
||||
},
|
||||
{
|
||||
id: 'cicd',
|
||||
title: 'GitLab CI/CD and Azure DevOps',
|
||||
body: 'Pipelines, and GitOps with Argo CD where the brief fits.',
|
||||
},
|
||||
],
|
||||
featuredCaseIds: ['innofocus', 'roesterei', 'bannier', 'elbwaerme', 'hdi', 'devdays'],
|
||||
sections: [
|
||||
{
|
||||
id: 'profile',
|
||||
headline: 'In thirty seconds',
|
||||
body: [
|
||||
'I work as a fullstack and DevOps engineer out of Tangermünde, freelance since April 2023. The CV as a PDF lists every station in full.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'featured-cases',
|
||||
headline: 'Selected cases',
|
||||
body: [
|
||||
'The public cases cover ERP and eCommerce, company IT and infrastructure, insurance and an annual developer event. Each one is written out in full on the projects page.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Selected projects', routeId: 'projects' },
|
||||
{ label: 'View the stack', routeId: 'stack' },
|
||||
{ label: 'Curriculum vitae as PDF', href: SITE_CONFIG.cvAssetPath },
|
||||
{ label: 'Write an email', href: `mailto:${SITE_CONFIG.contactEmail}` },
|
||||
],
|
||||
};
|
||||
416
src/app/core/content/en/services.ts
Normal file
416
src/app/core/content/en/services.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { type ServicesPageCopy } from '../content.contracts';
|
||||
|
||||
export const SERVICES_EN: ServicesPageCopy = {
|
||||
routeId: 'services',
|
||||
title: 'Services | Antonio Ledebuhr',
|
||||
description:
|
||||
'Servers and networks, platform operations, software and data, AI workflows. One technical contact from the first briefing through to operation and handover.',
|
||||
hero: {
|
||||
headline: 'Four areas of work, one technical contact',
|
||||
proof:
|
||||
'Servers, networks and virtualisation; platform operations; software and data; AI workflows. Every area names the public cases that back it.',
|
||||
playfulLine: 'Cables before clouds',
|
||||
body: [
|
||||
'I start where the infrastructure actually sits: the servers, the network, the machines a company depends on every morning. From there the work moves upward — the platform they run on, the software on top, and an AI workflow only where it earns its place.',
|
||||
'Every area below follows the same order: the situation, the diagnosis, the implementation, then operation and handover. The public cases sit at the end of each area, so you can check the claim against real work.',
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
id: 'approach',
|
||||
headline: 'How an engagement runs',
|
||||
body: [
|
||||
'Situation, diagnosis, implementation, operation and handover. The order is the same in all four areas, which is what makes them comparable.',
|
||||
'Nothing starts with a product name. It starts with what the business has to keep doing on Monday morning, and with the layer that carries it.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
headline: 'Who you actually work with',
|
||||
body: [
|
||||
'I am your technical point of contact. You talk to me about the brief, about the work and about the invoice, and I stay the same person through diagnosis, implementation and operation.',
|
||||
'The core team is two people, working between Tangermünde and Magdeburg. My colleague comes from business administration and takes on the commercial and organisational side, including the paperwork. He is not a second engineer or developer, and I do not present him as one.',
|
||||
'Around that sits a curated network of specialists. When a brief fits one of them better, I say so, and either hand it over openly or deliver it together with them. Availability does not decide the answer; fit does.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'boundaries',
|
||||
headline: 'What I do not sell along with it',
|
||||
body: [
|
||||
'Work that falls outside an area is named in the briefing, not in the small print. If a change is not needed, or the effort is not worth the result, you hear that from me before anybody writes an invoice.',
|
||||
'Delivered work and what is on offer are listed separately in every area. Where a figure is quoted, it comes from the project it belongs to. Where there is no figure, there is no figure.',
|
||||
],
|
||||
},
|
||||
],
|
||||
ctas: [
|
||||
{ label: 'Servers and networks', routeId: 'services', fragment: 'infrastructure-network' },
|
||||
{ label: 'Platform and operations', routeId: 'services', fragment: 'platform-operations' },
|
||||
{ label: 'Software and data', routeId: 'services', fragment: 'software-data' },
|
||||
{ label: 'AI workflows', routeId: 'services', fragment: 'ai-workflows' },
|
||||
{ label: 'Start a project briefing', routeId: 'contact' },
|
||||
{ label: 'See the public cases', routeId: 'projects' },
|
||||
],
|
||||
sectionsHeading: 'The four areas',
|
||||
deliveredOfferingsHeading: 'Delivered work',
|
||||
offerOfferingsHeading: 'Offered, scoped per engagement',
|
||||
offeringCaseBackedLabel: 'Backed by the public case {case}.',
|
||||
evidenceHeading: 'Public proof for this area',
|
||||
inquiryHeading: 'Send a briefing',
|
||||
systemsMap: {
|
||||
heading: 'How the four areas connect',
|
||||
intro:
|
||||
'Infrastructure carries the platform, the platform carries the software, and the AI work sits on top of all three. The public cases hang off the area they belong to.',
|
||||
},
|
||||
serviceSections: [
|
||||
{
|
||||
id: 'infrastructure-network',
|
||||
title: 'Servers, networks and on-premise infrastructure',
|
||||
lead: 'The layer everything else stands on — servers, the network, virtualisation and the on-premise structures a company runs its day on.',
|
||||
situation: {
|
||||
id: 'infrastructure-network-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Infrastructure that grew rather than got planned is the normal starting point. One server quietly does several jobs, the switch in the back room has no documentation, and whoever set it up left years ago.',
|
||||
'It usually still works. The cost shows up as small delays, unclear responsibility, and a bad week whenever something fails.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'infrastructure-network-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I start by writing down what actually runs: which machines exist, what depends on them, and which paths have to stay up during working hours.',
|
||||
'Then I separate what is worth keeping from what is only still there because nobody dared to touch it. That list is what we decide on — not a product catalogue.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'infrastructure-network-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Server structures and virtualisation sized for the company in front of me, a network with a shape somebody can follow, and on-premise systems where keeping the data in the building is the point. At Rösterei Tangermünde the virtualisation runs on Proxmox.',
|
||||
'On-premise stays a decision rather than a fallback. I prefer boring, maintainable building blocks, and if a smaller setup does the job, that is what gets built.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'infrastructure-network-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Afterwards I stay on it, or an internal team takes over from written structures. Handover means named responsibilities and paths somebody else can follow; nothing in the infrastructure should depend on my memory.',
|
||||
'The employee workstations and the everyday devices hanging off that infrastructure are part of the same operational work — setup, replacement and day-to-day support, where that is part of the arrangement. They are a subordinate task, not what the area is about.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'infra-network-and-server-structures',
|
||||
title: 'Network and server structures',
|
||||
body: 'Networks and server structures built to fit the company they serve: clear segments, documented paths, and wiring somebody else can follow. At Rösterei Tangermünde that layer has been mine since October 2023.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-virtualisation-on-premise',
|
||||
title: 'Virtualisation on your own hardware',
|
||||
body: 'Several services on your own hardware instead of several boxes. Virtualisation with Proxmox makes maintenance, moves and recovery predictable without handing the systems out of the building.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'infra-whole-it-small-business',
|
||||
title: 'The whole IT of a small company',
|
||||
body: 'One contact for all of it, over years rather than a project window. At Fahrschule Bannier that arrangement has run since roughly 2018. I support and operate the specialist software the school uses; I did not develop it.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'bannier',
|
||||
},
|
||||
{
|
||||
id: 'infra-infrastructure-responsibility',
|
||||
title: 'Infrastructure under one responsibility',
|
||||
body: 'Infrastructure, the everyday services and the access to them under one responsibility, instead of three separate arrangements. At Elbwärme that has been mine since November 2025.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'elbwaerme',
|
||||
},
|
||||
{
|
||||
id: 'infra-inventory-and-risk-review',
|
||||
title: 'A look at the infrastructure before you decide',
|
||||
body: 'A read of what you already have before money is committed to new hardware or a move, with the single points of failure written down. Scope and depth are agreed per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'infrastructure-network-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'Fahrschule Bannier is the longest-running of the six public cases: the whole IT of the driving school, accompanied since roughly 2018. Elbwärme is the most recent — infrastructure, Microsoft 365 and the website since November 2025. At Rösterei Tangermünde the same work sits inside full technical responsibility, starting at the network and server structures.',
|
||||
caseIds: ['bannier', 'elbwaerme', 'roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'infrastructure-network-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what runs today, what has to stay available during working hours, and what prompted the question.',
|
||||
'The briefing form only opens your local mail client with the text filled in. Nothing is sent to this server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'platform-operations',
|
||||
title: 'Platforms, containers and operations',
|
||||
lead: 'The layer where software actually runs — Microsoft 365, containers, Kubernetes, pipelines, and the arrangement that keeps them running afterwards.',
|
||||
situation: {
|
||||
id: 'platform-operations-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'Applications sit on single hosts or on a managed service, deployment is a manual routine, and the development, test and production environments have drifted apart.',
|
||||
'The Microsoft 365 tenant is often in the same state: it works, but nobody can say who owns it or what is switched on. That is platform operations too, under a different name.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'platform-operations-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I look at what has to be identical across environments, what genuinely needs a cluster, and what is fine exactly as it is.',
|
||||
'A cluster is not the goal. If a container on one host and a working pipeline solve the problem, the cluster stays in the drawer.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'platform-operations-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Containers with Docker, Kubernetes on-premise or on Azure AKS, and the migration of development, QA and production onto it. At HDI Specialty those three environments moved off Azure App Services onto Azure AKS.',
|
||||
'Pipelines with Azure DevOps or GitLab CI/CD, and GitOps with Argo CD where the repository should be the source of truth. Microsoft 365 belongs in the same picture: the tenant, the accounts, and the services a company uses every day.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'platform-operations-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Operation means keeping the platform boring: rollouts that behave the same way every time, and a state you can read out of Git rather than off one machine.',
|
||||
'Handover is written down and walked through with the people taking it on. Where accounts, employee devices and the everyday services are part of that arrangement, they stay a subordinate operational task — not the reason the platform exists.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'platform-microsoft-365',
|
||||
title: 'Microsoft 365 set up and operated',
|
||||
body: 'Tenant, accounts and the everyday services set up and operated, so the people using them do not have to think about them. At Rösterei Tangermünde and at Elbwärme that layer is mine.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'platform-containers-and-kubernetes',
|
||||
title: 'Containers and Kubernetes',
|
||||
body: 'Docker and Kubernetes on-premise or on Azure AKS, including the day-to-day operation of what runs on the cluster. At HDI Specialty the development, QA and production environments moved off Azure App Services onto Azure AKS.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'platform-pipelines-and-gitops',
|
||||
title: 'Build pipelines and GitOps',
|
||||
body: 'Build, test and deploy as one repeatable process: pipelines in Azure DevOps or GitLab CI/CD, deployments driven from the repository through Argo CD. That is how the Innofocus / reifen.com application reaches its cluster.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'platform-aligning-environments',
|
||||
title: 'Bringing environments back into line',
|
||||
body: 'Development, test and production brought onto the same footing, so a fault does not first appear in production. The cut depends on the tooling already in place and is agreed up front.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'platform-handover-to-your-team',
|
||||
title: 'A handover your team can actually use',
|
||||
body: 'Written runbooks and a walk-through with the people taking the platform on: access, routines, recurring tasks, who to call. The scope is agreed per engagement, because it depends on who is taking over.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'platform-operations-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'At HDI Specialty the development, QA and production environments were migrated from Azure App Services onto a Kubernetes cluster on Azure AKS, with the pipelines in Azure DevOps. At Rösterei Tangermünde the platform layer — Microsoft 365, Docker, Kubernetes and GitLab CE — sits inside my full technical responsibility. At Elbwärme it is the infrastructure and Microsoft 365, since November 2025.',
|
||||
caseIds: ['hdi', 'roesterei', 'elbwaerme'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'platform-operations-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what runs where today, how a release reaches production, and which environments are supposed to match.',
|
||||
'The briefing only opens your local mail client. Nothing reaches this server.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'software-data',
|
||||
title: 'Product software, APIs and data',
|
||||
lead: 'Fullstack product work and the data underneath it — APIs, SQL and T-SQL, and migrations that have to survive an audit.',
|
||||
situation: {
|
||||
id: 'software-data-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'A product needs something the current structure cannot carry. Or the data sits in a schema that grew for years and now blocks every change.',
|
||||
'Both usually arrive as one sentence from the business side, not as a specification.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'software-data-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'I take that sentence apart into the domain, the data and the interfaces, in that order, and say plainly which part is the expensive one.',
|
||||
'For data work that means reading the legacy structure before touching it: what is real data, what is a workaround somebody typed in years ago, and what an auditor will need to see.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'software-data-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Fullstack delivery with Angular and TypeScript on the surface, C# and .NET or Java and Spring behind it, REST APIs, and sign-in through Keycloak or Okta with OAuth 2 and OIDC.',
|
||||
'Data work in Microsoft SQL Server, with T-SQL stored procedures for transformation, normalisation and cleansing. At Innofocus / reifen.com the migration run went from around eight hours to about 90 minutes, at a larger feature scope and higher data quality.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'software-data-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
'Software is handed over with its tests, its migration scripts and the documentation the next person needs. A migration nobody else can rerun is not finished.',
|
||||
'Where I stay on afterwards, that covers the running system, the data behind it and the next migration round. You keep the same contact you had while it was written.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'software-fullstack-product-software',
|
||||
title: 'Fullstack product software',
|
||||
body: 'Angular on the front, C# and .NET or Java and Spring behind it, a REST API, and tests written alongside the code rather than after it. That is how the exposure-management software at HDI Specialty was built.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'hdi',
|
||||
},
|
||||
{
|
||||
id: 'software-legacy-data-migration',
|
||||
title: 'Legacy data migration',
|
||||
body: 'Moving a grown data store into a normalised target structure inside Microsoft SQL Server, including the documentation an auditor asks for, and picking it up where another provider left off. At Innofocus / reifen.com the migration is mine alone, and a run takes about 90 minutes instead of around eight hours, at a larger feature scope and higher data quality.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-sql-and-tsql-data-work',
|
||||
title: 'T-SQL data work',
|
||||
body: 'Stored procedures for transformation, normalisation and cleansing, written where the data already sits rather than routed through the application, and written so the run can be repeated and checked rather than trusted.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'innofocus',
|
||||
},
|
||||
{
|
||||
id: 'software-replacing-a-legacy-system',
|
||||
title: 'Replacing a system that is still running',
|
||||
body: 'Replacing a grown system step by step without stopping the business. The order, the cut and the way back are agreed before the first line of code.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'software-data-model-and-api-review',
|
||||
title: 'A review of the data model and the APIs',
|
||||
body: 'A written assessment of the data model, the interfaces and the test coverage of an existing application, with the risks named and put in order. Scope is agreed up front.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'software-data-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: 'Innofocus / reifen.com carries this area: sole responsibility for moving a grown ERP data store into a normalised system, with the migration run down from around eight hours to about 90 minutes at a larger feature scope. The Magdeburger Dev Days work covers the other half — the public site plus the internal planning tools behind it, and a Kubernetes-based streaming platform for the 2021 edition. HDI Specialty shows the same fullstack shape under regulation, with evaluations across the whole policy set for BaFin.',
|
||||
caseIds: ['innofocus', 'devdays', 'hdi'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'software-data-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: what the software or the data has to do, what exists today, and whether there is a date it has to be ready for.',
|
||||
'The briefing assembles a message in your local mail client. It is not sent from this site.',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ai-workflows',
|
||||
title: 'AI workflows',
|
||||
lead: 'Two AI tools are running in daily use at Rösterei Tangermünde. Everything beyond those two is an offer that gets scoped, and reviewed, for your case.',
|
||||
situation: {
|
||||
id: 'ai-workflows-situation',
|
||||
headline: 'Situation',
|
||||
body: [
|
||||
'A process eats time every week: sorting through customer reviews, repeating the same handful of steps in a shop backend, moving text from one place into another.',
|
||||
'Whether a model helps depends entirely on that process. Often the honest answer arrives before any model does.',
|
||||
],
|
||||
},
|
||||
diagnosis: {
|
||||
id: 'ai-workflows-diagnosis',
|
||||
headline: 'Diagnosis',
|
||||
body: [
|
||||
'The process comes first: how often it runs, what a wrong answer costs, and which data would have to leave the building for a model to see it.',
|
||||
'If the process itself is the problem, I say so. A model laid over a broken process only makes the mess faster.',
|
||||
],
|
||||
},
|
||||
implementation: {
|
||||
id: 'ai-workflows-implementation',
|
||||
headline: 'Implementation',
|
||||
body: [
|
||||
'Where it holds up, a narrow tool goes into the chain that already exists. At Rösterei Tangermünde there are two of them: one categorises customer reviews, one speeds up managing the new shop — comparable to Shopify Sidekick and wired into the same internal tooling.',
|
||||
'Everything beyond those two — process analysis, external models and APIs, a locally run model, retrieval over your own documents, a person keeping approval, several steps handing over to each other — is offered and scoped per engagement. I do not present it as delivered client work.',
|
||||
],
|
||||
},
|
||||
operation: {
|
||||
id: 'ai-workflows-operation',
|
||||
headline: 'Operation and handover',
|
||||
body: [
|
||||
"The two delivered tools live inside the roastery's own tool chain and are maintained there, alongside the rest of its technology.",
|
||||
'Handover means a readable integration: where the tool sits, what it is allowed to see, what gets logged, and how to switch it off. Not a chat window bolted on beside the shop.',
|
||||
],
|
||||
},
|
||||
offerings: [
|
||||
{
|
||||
id: 'ai-review-categorisation',
|
||||
title: 'Categorising customer reviews',
|
||||
body: 'An AI tool that sorts incoming customer reviews into categories. Delivered for Rösterei Tangermünde and running inside its internal tooling.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-shop-management-assistant',
|
||||
title: 'Assistant for shop management',
|
||||
body: 'An AI tool for managing the new shop quickly, comparable to Shopify Sidekick and integrated into the same internal tool chain. Delivered for Rösterei Tangermünde.',
|
||||
status: 'delivered',
|
||||
referenceCaseId: 'roesterei',
|
||||
},
|
||||
{
|
||||
id: 'ai-process-analysis-first',
|
||||
title: 'Process analysis before any model',
|
||||
body: "We open the process up first and decide whether a model belongs in it at all. The answer is worked out for your case; it is not carried over from somebody else's.",
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-external-or-local-models',
|
||||
title: 'External models, APIs or a local model',
|
||||
body: 'Connecting an existing model or API to a process, or checking whether running a model on your own hardware is viable. Offered and scoped per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-retrieval-with-data-boundary',
|
||||
title: 'Retrieval over your own documents, with a person in the loop',
|
||||
body: 'If internal texts should ground the answers, the retrieval path and the data boundary are designed against your corpus, and we agree where a person keeps approval. Scoped per engagement.',
|
||||
status: 'offer',
|
||||
},
|
||||
{
|
||||
id: 'ai-human-approval-and-multi-step',
|
||||
title: 'Several specialised steps in one workflow',
|
||||
body: 'Where a task splits into separate steps that hand over to each other, and where a person approves or corrects, the composition is sketched and reviewed against your brief before any of it is built.',
|
||||
status: 'offer',
|
||||
},
|
||||
],
|
||||
evidence: {
|
||||
id: 'ai-workflows-evidence',
|
||||
heading: 'Public proof for this area',
|
||||
note: "Rösterei Tangermünde is the only public case behind this area, and it carries exactly two delivered tools: the review categorisation and the shop assistant. Both run inside the company's own tool chain, not as a product. Everything else listed in this area is an offer, scoped per engagement. I hold an economic participation in Rösterei Tangermünde, which is why that is stated on the case itself.",
|
||||
caseIds: ['roesterei'],
|
||||
},
|
||||
inquiry: {
|
||||
id: 'ai-workflows-inquiry',
|
||||
headline: 'Send a briefing',
|
||||
body: [
|
||||
'Worth putting in: which process is costing time, how often it runs, and which data is not allowed to leave the company.',
|
||||
'The briefing only opens your local mail client. Nothing is sent to this server, which is also the honest starting point for a conversation about data.',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
46
src/app/core/content/en/site.ts
Normal file
46
src/app/core/content/en/site.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { type SiteContent } from '../content.contracts';
|
||||
import { CASES_EN } from './cases';
|
||||
import { HOME_EN } from './home';
|
||||
import { PITCH_EN } from './pitch';
|
||||
import {
|
||||
ABOUT_EN,
|
||||
CONTACT_EN,
|
||||
IMPRINT_EN,
|
||||
NOT_FOUND_EN,
|
||||
PRIVACY_EN,
|
||||
PROJECTS_EN,
|
||||
STACK_EN,
|
||||
} from './pages';
|
||||
import { SERVICES_EN } from './services';
|
||||
|
||||
export const SITE_CONTENT_EN: SiteContent = {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_EN,
|
||||
cases: CASES_EN,
|
||||
contact: CONTACT_EN,
|
||||
projects: PROJECTS_EN,
|
||||
stack: STACK_EN,
|
||||
legal: {
|
||||
imprint: IMPRINT_EN,
|
||||
privacy: PRIVACY_EN,
|
||||
},
|
||||
seo: {
|
||||
jobTitle: 'Fullstack and DevOps engineer',
|
||||
professionalServiceName: 'Infrastructure, software and DevOps services',
|
||||
professionalServiceDescription:
|
||||
'Servers and networks, platform operations, software and data, and AI workflows — with one technical contact from the first briefing through to operation.',
|
||||
},
|
||||
pages: {
|
||||
home: HOME_EN,
|
||||
pitch: PITCH_EN,
|
||||
services: SERVICES_EN,
|
||||
projects: PROJECTS_EN,
|
||||
stack: STACK_EN,
|
||||
about: ABOUT_EN,
|
||||
contact: CONTACT_EN,
|
||||
imprint: IMPRINT_EN,
|
||||
privacy: PRIVACY_EN,
|
||||
notFound: NOT_FOUND_EN,
|
||||
},
|
||||
};
|
||||
35
src/app/core/content/shell-copy.ts
Normal file
35
src/app/core/content/shell-copy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
|
||||
export interface ShellCopy {
|
||||
readonly skipLink: string;
|
||||
readonly primaryNavLabel: string;
|
||||
readonly footerNavLabel: string;
|
||||
readonly menuOpen: string;
|
||||
readonly menuClose: string;
|
||||
readonly languageSwitch: string;
|
||||
readonly otherLocaleName: string;
|
||||
readonly contactCta: string;
|
||||
}
|
||||
|
||||
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
de: {
|
||||
skipLink: 'Zum Inhalt springen',
|
||||
primaryNavLabel: 'Hauptnavigation',
|
||||
footerNavLabel: 'Fußzeilen-Navigation',
|
||||
menuOpen: 'Menü öffnen',
|
||||
menuClose: 'Menü schließen',
|
||||
languageSwitch: 'Zur englischen Version wechseln',
|
||||
otherLocaleName: 'English',
|
||||
contactCta: 'Kontakt',
|
||||
},
|
||||
en: {
|
||||
skipLink: 'Skip to content',
|
||||
primaryNavLabel: 'Primary navigation',
|
||||
footerNavLabel: 'Footer navigation',
|
||||
menuOpen: 'Open menu',
|
||||
menuClose: 'Close menu',
|
||||
languageSwitch: 'Switch to the German version',
|
||||
otherLocaleName: 'Deutsch',
|
||||
contactCta: 'Contact',
|
||||
},
|
||||
};
|
||||
70
src/app/core/content/signature-copy.spec.ts
Normal file
70
src/app/core/content/signature-copy.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { COMMAND_IDS } from '../commands/command-ids';
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { SIGNATURE_COPY } from './signature-copy';
|
||||
|
||||
function leafPaths(value: unknown, prefix = ''): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.keys(value).flatMap((key) => {
|
||||
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||
return leafPaths((value as Record<string, unknown>)[key], next);
|
||||
});
|
||||
}
|
||||
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.values(value).flatMap((entry) => collectStrings(entry));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
describe('SIGNATURE_COPY', () => {
|
||||
it('exposes the same key structure in both locales', () => {
|
||||
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
|
||||
|
||||
for (const keys of rest) {
|
||||
expect(keys).toEqual(first);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every string non-empty', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not import from src/app/shared', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/app/core/content/signature-copy.ts'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).not.toMatch(/from ['"][^'"]*\/shared\//);
|
||||
});
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
for (const id of COMMAND_IDS) {
|
||||
expect(descriptions[id].trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
206
src/app/core/content/signature-copy.ts
Normal file
206
src/app/core/content/signature-copy.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { type CommandId } from '../commands/command-ids';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly terminal: {
|
||||
readonly triggerLabel: string;
|
||||
readonly triggerAccessibleName: string;
|
||||
readonly triggerTooltip: string;
|
||||
readonly triggerTooltipApple: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly shortcutHintApple: string;
|
||||
readonly prompt: string;
|
||||
readonly panelLabel: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly collapseLabel: string;
|
||||
readonly collapseTooltip: string;
|
||||
readonly maximizeLabel: string;
|
||||
readonly maximizeTooltip: string;
|
||||
readonly restoreLabel: string;
|
||||
readonly restoreTooltip: string;
|
||||
readonly outputLabel: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly unknownTarget: string;
|
||||
readonly validTargetsLabel: string;
|
||||
readonly helpIntro: string;
|
||||
readonly helpGrammar: string;
|
||||
readonly helpTargetsIntro: string;
|
||||
readonly historyEmpty: string;
|
||||
readonly historyIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly jumping: string;
|
||||
readonly incompleteHint: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
readonly diagramDescription: string;
|
||||
readonly listHeading: string;
|
||||
readonly legendHeading: string;
|
||||
readonly relationshipLabel: string;
|
||||
readonly dialogTitlePattern: string;
|
||||
readonly closeLabel: string;
|
||||
readonly closeTooltip: string;
|
||||
readonly sectionLinkPattern: string;
|
||||
readonly caseLinkPattern: string;
|
||||
readonly nodeLinkHint: string;
|
||||
readonly legend: {
|
||||
readonly infrastructure: string;
|
||||
readonly platform: string;
|
||||
readonly software: string;
|
||||
readonly ai: string;
|
||||
readonly project: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
triggerAccessibleName: 'Terminal öffnen',
|
||||
triggerTooltip: 'Terminal öffnen (Strg+K)',
|
||||
triggerTooltipApple: 'Terminal öffnen (⌘K)',
|
||||
shortcutHint: 'Strg+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
collapseLabel: 'Terminal einklappen',
|
||||
collapseTooltip: 'Einklappen (Esc)',
|
||||
maximizeLabel: 'Terminal vergrößern',
|
||||
maximizeTooltip: 'Vergrößern',
|
||||
restoreLabel: 'Terminal verkleinern',
|
||||
restoreTooltip: 'Verkleinern',
|
||||
outputLabel: 'Ausgabe',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
unknownTarget: 'Unbekanntes Ziel: {target}',
|
||||
validTargetsLabel: 'Gültige Ziele:',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate <ziel> [<bereich>]',
|
||||
helpTargetsIntro: 'Ziele für navigate:',
|
||||
historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.',
|
||||
historyIntro: 'Eingegebene Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
jumping: 'Sprung zu {target}.',
|
||||
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
|
||||
commandDescriptions: {
|
||||
help: 'Listet die Grammatik und jeden Befehl.',
|
||||
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
|
||||
clear: 'Leert das Ausgabebuch.',
|
||||
brew: 'Brüht einen Kaffee auf. Sonst passiert nichts.',
|
||||
rev: 'Gibt kurz Gas. Sonst passiert nichts.',
|
||||
navigate:
|
||||
'Wechselt zu einer Seite, springt in einen Leistungsbereich oder öffnet den Lebenslauf.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systemkarte',
|
||||
intro:
|
||||
'Wie die vier Leistungsbereiche zusammenhängen — von Server und Netz bis zu den KI-Abläufen, und wo die öffentlichen Fälle ansetzen.',
|
||||
diagramDescription:
|
||||
'Diagramm der vier Leistungsbereiche, der öffentlichen Fälle und ihrer Verbindungen.',
|
||||
listHeading: 'Die Karte als Liste',
|
||||
legendHeading: 'Legende',
|
||||
relationshipLabel: 'Verbunden mit {targets}.',
|
||||
dialogTitlePattern: '{node} im Überblick',
|
||||
closeLabel: 'Kurzprofil schließen',
|
||||
closeTooltip: 'Schließen (Esc)',
|
||||
sectionLinkPattern: 'Zum Bereich {node}',
|
||||
caseLinkPattern: 'Zum Fall {node}',
|
||||
nodeLinkHint:
|
||||
'Jeder Knoten bleibt ein normaler Link — Mittelklick oder Klick mit gedrückter Zusatztaste öffnet das Ziel direkt in einem neuen Tab.',
|
||||
legend: {
|
||||
infrastructure: 'Server und Netz',
|
||||
platform: 'Plattform und Betrieb',
|
||||
software: 'Software und Daten',
|
||||
ai: 'KI-Abläufe',
|
||||
project: 'Öffentlicher Fall',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
triggerAccessibleName: 'Open the terminal',
|
||||
triggerTooltip: 'Open the terminal (Ctrl+K)',
|
||||
triggerTooltipApple: 'Open the terminal (⌘K)',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
collapseLabel: 'Collapse the terminal',
|
||||
collapseTooltip: 'Collapse (Esc)',
|
||||
maximizeLabel: 'Maximise the terminal',
|
||||
maximizeTooltip: 'Maximise',
|
||||
restoreLabel: 'Restore the terminal',
|
||||
restoreTooltip: 'Restore the previous size',
|
||||
outputLabel: 'Output',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
unknownTarget: 'Unknown target: {target}',
|
||||
validTargetsLabel: 'Valid targets:',
|
||||
helpIntro: 'Available commands:',
|
||||
helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate <target> [<section>]',
|
||||
helpTargetsIntro: 'Targets for navigate:',
|
||||
historyEmpty: 'No commands have been entered in this session.',
|
||||
historyIntro: 'Entered commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
jumping: 'Jumping to {target}.',
|
||||
incompleteHint: 'Incomplete command. Possible targets:',
|
||||
commandDescriptions: {
|
||||
help: 'Lists the grammar and every command.',
|
||||
history: 'Lists the commands entered in this session.',
|
||||
clear: 'Clears the output log.',
|
||||
brew: 'Pours a coffee. Nothing else happens.',
|
||||
rev: 'Blips the throttle. Nothing else happens.',
|
||||
navigate: 'Goes to a page, jumps to a section of the services page, or opens the CV.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||
rev: 'Revs climbing, the content stays calm.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systems map',
|
||||
intro: 'How the four areas connect, and which public case sits under each of them.',
|
||||
diagramDescription:
|
||||
'Diagram of the four service areas, the public cases and the connections between them.',
|
||||
listHeading: 'The same map as a list',
|
||||
legendHeading: 'Legend',
|
||||
relationshipLabel: 'Connected to {targets}.',
|
||||
dialogTitlePattern: '{node} — in short',
|
||||
closeLabel: 'Close the details',
|
||||
closeTooltip: 'Close (Esc)',
|
||||
sectionLinkPattern: 'Open the full section: {node}',
|
||||
caseLinkPattern: 'Open the case: {node}',
|
||||
nodeLinkHint:
|
||||
'Every node is a normal link as well — middle-click or modifier-click opens it straight in a new tab.',
|
||||
legend: {
|
||||
infrastructure: 'Servers and networks',
|
||||
platform: 'Platform and operations',
|
||||
software: 'Software and data',
|
||||
ai: 'AI workflows',
|
||||
project: 'Public case',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
15
src/app/core/content/site-config.ts
Normal file
15
src/app/core/content/site-config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const SITE_CONFIG: {
|
||||
readonly personName: string;
|
||||
readonly contactEmail: string;
|
||||
readonly siteOrigin: string;
|
||||
readonly cvAssetPath: string;
|
||||
readonly cvDownloadFileName: string;
|
||||
readonly calendarUrl: string | null;
|
||||
} = {
|
||||
personName: 'Antonio Ledebuhr',
|
||||
contactEmail: 'info@antoniolede.de',
|
||||
siteOrigin: 'https://antoniolede.de',
|
||||
cvAssetPath: '/cv/CV.pdf',
|
||||
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
|
||||
calendarUrl: null,
|
||||
};
|
||||
9
src/app/core/content/site-content.ts
Normal file
9
src/app/core/content/site-content.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type SiteContent } from './content.contracts';
|
||||
import { SITE_CONTENT_DE } from './de/site';
|
||||
import { SITE_CONTENT_EN } from './en/site';
|
||||
|
||||
export const SITE_CONTENT_DATA: Record<AppLocale, SiteContent> = {
|
||||
de: SITE_CONTENT_DE,
|
||||
en: SITE_CONTENT_EN,
|
||||
};
|
||||
11
src/app/core/i18n/locale.resolver.ts
Normal file
11
src/app/core/i18n/locale.resolver.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { type ResolveFn } from '@angular/router';
|
||||
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from './locale';
|
||||
import { LocaleService } from './locale.service';
|
||||
|
||||
export const localeResolver: ResolveFn<AppLocale> = (route) => {
|
||||
const localeValue: unknown = route.data['locale'];
|
||||
const locale = isAppLocale(localeValue) ? localeValue : DEFAULT_LOCALE;
|
||||
inject(LocaleService).setLocale(locale);
|
||||
return locale;
|
||||
};
|
||||
25
src/app/core/i18n/locale.service.ts
Normal file
25
src/app/core/i18n/locale.service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { DEFAULT_LOCALE, LOCALE_HTML_LANG, type AppLocale } from './locale';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocaleService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly localeSignal = signal<AppLocale>(DEFAULT_LOCALE);
|
||||
|
||||
readonly locale = this.localeSignal.asReadonly();
|
||||
readonly htmlLang = computed(() => LOCALE_HTML_LANG[this.localeSignal()]);
|
||||
|
||||
constructor() {
|
||||
this.applyHtmlLang(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
setLocale(locale: AppLocale): void {
|
||||
this.localeSignal.set(locale);
|
||||
this.applyHtmlLang(locale);
|
||||
}
|
||||
|
||||
private applyHtmlLang(locale: AppLocale): void {
|
||||
this.document.documentElement.lang = LOCALE_HTML_LANG[locale];
|
||||
}
|
||||
}
|
||||
44
src/app/core/i18n/locale.spec.ts
Normal file
44
src/app/core/i18n/locale.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DEFAULT_LOCALE, isAppLocale, otherLocale } from './locale';
|
||||
import { LocaleService } from './locale.service';
|
||||
|
||||
describe('locale helpers', () => {
|
||||
it('defaults to German', () => {
|
||||
expect(DEFAULT_LOCALE).toBe('de');
|
||||
});
|
||||
|
||||
it('accepts only de and en', () => {
|
||||
expect(isAppLocale('de')).toBe(true);
|
||||
expect(isAppLocale('en')).toBe(true);
|
||||
expect(isAppLocale('fr')).toBe(false);
|
||||
expect(isAppLocale('')).toBe(false);
|
||||
expect(isAppLocale(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips the other locale', () => {
|
||||
expect(otherLocale('de')).toBe('en');
|
||||
expect(otherLocale('en')).toBe('de');
|
||||
expect(otherLocale(otherLocale('de'))).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocaleService', () => {
|
||||
it('updates the locale signal and the document lang attribute', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(LocaleService);
|
||||
const document = TestBed.inject(DOCUMENT);
|
||||
|
||||
expect(service.locale()).toBe('de');
|
||||
expect(document.documentElement.lang).toBe('de-DE');
|
||||
|
||||
service.setLocale('en');
|
||||
expect(service.locale()).toBe('en');
|
||||
expect(service.htmlLang()).toBe('en');
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
|
||||
service.setLocale('de');
|
||||
expect(service.locale()).toBe('de');
|
||||
expect(document.documentElement.lang).toBe('de-DE');
|
||||
});
|
||||
});
|
||||
18
src/app/core/i18n/locale.ts
Normal file
18
src/app/core/i18n/locale.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type AppLocale = 'de' | 'en';
|
||||
|
||||
export const APP_LOCALES: readonly AppLocale[] = ['de', 'en'];
|
||||
|
||||
export const DEFAULT_LOCALE: AppLocale = 'de';
|
||||
|
||||
export const LOCALE_HTML_LANG: Record<AppLocale, string> = {
|
||||
de: 'de-DE',
|
||||
en: 'en',
|
||||
};
|
||||
|
||||
export function isAppLocale(value: unknown): value is AppLocale {
|
||||
return value === 'de' || value === 'en';
|
||||
}
|
||||
|
||||
export function otherLocale(locale: AppLocale): AppLocale {
|
||||
return locale === 'de' ? 'en' : 'de';
|
||||
}
|
||||
51
src/app/core/navigation/navigation.service.spec.ts
Normal file
51
src/app/core/navigation/navigation.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
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/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { PRIMARY_NAV } from './navigation';
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a German route to its English counterpart and back', async () => {
|
||||
const harness = await RouterTestingHarness.create();
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
|
||||
await harness.navigateByUrl('/projekte');
|
||||
expect(navigation.activeRouteId()).toBe('projects');
|
||||
expect(navigation.alternateLocaleLink()).toEqual(['/', 'en', 'projects']);
|
||||
|
||||
await harness.navigateByUrl('/en/projects');
|
||||
expect(navigation.activeRouteId()).toBe('projects');
|
||||
expect(navigation.alternateLocaleLink()).toEqual(['/', 'projekte']);
|
||||
});
|
||||
|
||||
it('builds links for the active locale', () => {
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
const locale = TestBed.inject(LocaleService);
|
||||
|
||||
locale.setLocale('de');
|
||||
expect(navigation.link('projects')).toEqual(['/', 'projekte']);
|
||||
expect(navigation.link('home')).toEqual(['/']);
|
||||
|
||||
locale.setLocale('en');
|
||||
expect(navigation.link('projects')).toEqual(['/', 'en', 'projects']);
|
||||
expect(navigation.link('home')).toEqual(['/', 'en']);
|
||||
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
|
||||
});
|
||||
|
||||
it('keeps primary nav to home, services, projects, about and contact', () => {
|
||||
const ids = PRIMARY_NAV.map((item) => item.routeId);
|
||||
expect(ids).toEqual(['home', 'services', 'projects', 'about', 'contact']);
|
||||
expect(ids).not.toContain('stack');
|
||||
expect(ids).not.toContain('pitch');
|
||||
});
|
||||
});
|
||||
75
src/app/core/navigation/navigation.service.ts
Normal file
75
src/app/core/navigation/navigation.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { otherLocale, type AppLocale } from '../i18n/locale';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { isAppRouteData } from '../routing/app-route-data';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
import { routeCommands } from '../routing/route-paths';
|
||||
import { FOOTER_NAV, PRIMARY_NAV, type NavItem } from './navigation';
|
||||
|
||||
export interface ResolvedNavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: string;
|
||||
readonly link: unknown[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NavigationService {
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly activeRouteIdSignal = signal<RouteId>('home');
|
||||
|
||||
readonly activeRouteId = this.activeRouteIdSignal.asReadonly();
|
||||
readonly cvHref = SITE_CONFIG.cvAssetPath;
|
||||
readonly contactLink = computed(() => this.link('contact'));
|
||||
readonly primaryNav = computed(() => this.resolveItems(PRIMARY_NAV, this.localeService.locale()));
|
||||
readonly footerNav = computed(() => this.resolveItems(FOOTER_NAV, this.localeService.locale()));
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => this.syncFromRouter());
|
||||
|
||||
this.syncFromRouter();
|
||||
}
|
||||
|
||||
link(routeId: RouteId, locale?: AppLocale): unknown[] {
|
||||
return routeCommands(routeId, locale ?? this.localeService.locale());
|
||||
}
|
||||
|
||||
alternateLocaleLink(): unknown[] {
|
||||
const targetLocale = otherLocale(this.localeService.locale());
|
||||
const current = this.activeRouteId();
|
||||
const routeId = current === 'notFound' ? 'home' : current;
|
||||
return routeCommands(routeId, targetLocale);
|
||||
}
|
||||
|
||||
private resolveItems(items: readonly NavItem[], locale: AppLocale): ResolvedNavItem[] {
|
||||
return items.map((item) => ({
|
||||
routeId: item.routeId,
|
||||
label: item.label[locale],
|
||||
link: routeCommands(item.routeId, locale),
|
||||
}));
|
||||
}
|
||||
|
||||
private syncFromRouter(): void {
|
||||
let snapshot = this.router.routerState.snapshot.root;
|
||||
|
||||
while (snapshot.firstChild) {
|
||||
snapshot = snapshot.firstChild;
|
||||
}
|
||||
|
||||
if (!isAppRouteData(snapshot.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.localeService.setLocale(snapshot.data.locale);
|
||||
this.activeRouteIdSignal.set(snapshot.data.routeId);
|
||||
}
|
||||
}
|
||||
45
src/app/core/navigation/navigation.ts
Normal file
45
src/app/core/navigation/navigation.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
|
||||
export interface NavItem {
|
||||
readonly routeId: RouteId;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
}
|
||||
|
||||
export const PRIMARY_NAV: readonly NavItem[] = [
|
||||
{
|
||||
routeId: 'home',
|
||||
label: { de: 'Startseite', en: 'Home' },
|
||||
},
|
||||
{
|
||||
routeId: 'services',
|
||||
label: { de: 'Leistungen', en: 'Services' },
|
||||
},
|
||||
{
|
||||
routeId: 'projects',
|
||||
label: { de: 'Projekte', en: 'Projects' },
|
||||
},
|
||||
{
|
||||
routeId: 'about',
|
||||
label: { de: 'Über mich', en: 'About' },
|
||||
},
|
||||
{
|
||||
routeId: 'contact',
|
||||
label: { de: 'Kontakt', en: 'Contact' },
|
||||
},
|
||||
];
|
||||
|
||||
export const FOOTER_NAV: readonly NavItem[] = [
|
||||
{
|
||||
routeId: 'imprint',
|
||||
label: { de: 'Impressum', en: 'Legal notice' },
|
||||
},
|
||||
{
|
||||
routeId: 'privacy',
|
||||
label: { de: 'Datenschutz', en: 'Privacy' },
|
||||
},
|
||||
{
|
||||
routeId: 'contact',
|
||||
label: { de: 'Kontakt', en: 'Contact' },
|
||||
},
|
||||
];
|
||||
25
src/app/core/platform/browser.spec.ts
Normal file
25
src/app/core/platform/browser.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PLATFORM_ID } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
isApplePlatform,
|
||||
isBrowserPlatform,
|
||||
prefersCoarsePointer,
|
||||
prefersReducedMotion,
|
||||
viewportMatches,
|
||||
} from './browser';
|
||||
|
||||
describe('browser platform helpers', () => {
|
||||
it('returns conservative defaults on the server without throwing', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: PLATFORM_ID, useValue: 'server' }],
|
||||
});
|
||||
|
||||
TestBed.runInInjectionContext(() => {
|
||||
expect(isBrowserPlatform()).toBe(false);
|
||||
expect(prefersReducedMotion()).toBe(false);
|
||||
expect(prefersCoarsePointer()).toBe(false);
|
||||
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
|
||||
expect(isApplePlatform()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
src/app/core/platform/browser.ts
Normal file
42
src/app/core/platform/browser.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
export function isBrowserPlatform(): boolean {
|
||||
return isPlatformBrowser(inject(PLATFORM_ID));
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return mediaQueryMatches('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
|
||||
export function prefersCoarsePointer(): boolean {
|
||||
return mediaQueryMatches('(pointer: coarse)');
|
||||
}
|
||||
|
||||
export function viewportMatches(query: string): boolean {
|
||||
return mediaQueryMatches(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard-labelling exception: there is no CSS media query for the Command key.
|
||||
* Used only to swap the terminal shortcut hint after hydration.
|
||||
*/
|
||||
export function isApplePlatform(): boolean {
|
||||
if (!isBrowserPlatform()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const navigatorWithHints = window.navigator as Navigator & {
|
||||
readonly userAgentData?: { readonly platform?: string };
|
||||
};
|
||||
const platform = navigatorWithHints.userAgentData?.platform ?? navigatorWithHints.platform ?? '';
|
||||
return /mac|iphone|ipad|ipod/i.test(platform);
|
||||
}
|
||||
|
||||
function mediaQueryMatches(query: string): boolean {
|
||||
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
16
src/app/core/routing/app-route-data.ts
Normal file
16
src/app/core/routing/app-route-data.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { isAppLocale, type AppLocale } from '../i18n/locale';
|
||||
import { type RouteId } from './route-ids';
|
||||
|
||||
export interface AppRouteData {
|
||||
readonly routeId: RouteId;
|
||||
readonly locale: AppLocale;
|
||||
}
|
||||
|
||||
export function isAppRouteData(value: unknown): value is AppRouteData {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record['routeId'] === 'string' && isAppLocale(record['locale']);
|
||||
}
|
||||
24
src/app/core/routing/route-ids.ts
Normal file
24
src/app/core/routing/route-ids.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type RouteId =
|
||||
| 'home'
|
||||
| 'pitch'
|
||||
| 'services'
|
||||
| 'projects'
|
||||
| 'stack'
|
||||
| 'about'
|
||||
| 'contact'
|
||||
| 'imprint'
|
||||
| 'privacy'
|
||||
| 'notFound';
|
||||
|
||||
export const ROUTE_IDS: readonly RouteId[] = [
|
||||
'home',
|
||||
'pitch',
|
||||
'services',
|
||||
'projects',
|
||||
'stack',
|
||||
'about',
|
||||
'contact',
|
||||
'imprint',
|
||||
'privacy',
|
||||
'notFound',
|
||||
];
|
||||
54
src/app/core/routing/route-paths.spec.ts
Normal file
54
src/app/core/routing/route-paths.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS, type RouteId } from './route-ids';
|
||||
import { prerenderablePaths, routePath, ROUTE_SEGMENTS } from './route-paths';
|
||||
|
||||
describe('route paths', () => {
|
||||
const addressableIds = ROUTE_IDS.filter((routeId) => routeId !== 'notFound');
|
||||
|
||||
it('defines every route id in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
expect(ROUTE_SEGMENTS[locale][routeId]).toBeTypeOf('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('has no duplicate addressable paths within a locale', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const paths = addressableIds.map((routeId) => routePath(routeId, locale));
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds the expected home and projects paths', () => {
|
||||
expect(routePath('home', 'de')).toBe('/');
|
||||
expect(routePath('home', 'en')).toBe('/en');
|
||||
expect(routePath('projects', 'de')).toBe('/projekte');
|
||||
expect(routePath('projects', 'en')).toBe('/en/projects');
|
||||
});
|
||||
|
||||
it('exposes pitch in both locales and keeps prerenderablePaths at 18', () => {
|
||||
expect(ROUTE_SEGMENTS.de.pitch).toBe('pitch');
|
||||
expect(ROUTE_SEGMENTS.en.pitch).toBe('pitch');
|
||||
expect(routePath('pitch', 'de')).toBe('/pitch');
|
||||
expect(routePath('pitch', 'en')).toBe('/en/pitch');
|
||||
expect(prerenderablePaths()).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
|
||||
const paths = prerenderablePaths();
|
||||
|
||||
expect(paths).toContain('/');
|
||||
expect(paths).toContain('/en');
|
||||
expect(paths).toContain('/projekte');
|
||||
expect(paths).toContain('/en/projects');
|
||||
expect(paths.some((path) => path.includes('**'))).toBe(false);
|
||||
expect(paths).not.toContain('');
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const routeId of addressableIds) {
|
||||
expect(paths).toContain(routePath(routeId as RouteId, locale));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
77
src/app/core/routing/route-paths.ts
Normal file
77
src/app/core/routing/route-paths.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { APP_LOCALES, type AppLocale } from '../i18n/locale';
|
||||
import { ROUTE_IDS, type RouteId } from './route-ids';
|
||||
|
||||
export const LOCALE_PREFIX: Record<AppLocale, string> = {
|
||||
de: '',
|
||||
en: 'en',
|
||||
};
|
||||
|
||||
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
|
||||
de: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'leistungen',
|
||||
projects: 'projekte',
|
||||
stack: 'stack',
|
||||
about: 'ueber-mich',
|
||||
contact: 'kontakt',
|
||||
imprint: 'impressum',
|
||||
privacy: 'datenschutz',
|
||||
notFound: '**',
|
||||
},
|
||||
en: {
|
||||
home: '',
|
||||
pitch: 'pitch',
|
||||
services: 'services',
|
||||
projects: 'projects',
|
||||
stack: 'stack',
|
||||
about: 'about',
|
||||
contact: 'contact',
|
||||
imprint: 'legal-notice',
|
||||
privacy: 'privacy',
|
||||
notFound: '**',
|
||||
},
|
||||
};
|
||||
|
||||
export function routePath(routeId: RouteId, locale: AppLocale): string {
|
||||
if (routeId === 'notFound') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prefix = LOCALE_PREFIX[locale];
|
||||
const segment = ROUTE_SEGMENTS[locale][routeId];
|
||||
const parts = [prefix, segment].filter((part) => part.length > 0);
|
||||
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
export function routeCommands(routeId: RouteId, locale: AppLocale): unknown[] {
|
||||
if (routeId === 'notFound') {
|
||||
return ['/'];
|
||||
}
|
||||
|
||||
const commands: string[] = ['/'];
|
||||
const prefix = LOCALE_PREFIX[locale];
|
||||
const segment = ROUTE_SEGMENTS[locale][routeId];
|
||||
|
||||
if (prefix.length > 0) {
|
||||
commands.push(prefix);
|
||||
}
|
||||
|
||||
if (segment.length > 0) {
|
||||
commands.push(...segment.split('/'));
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function prerenderablePaths(): readonly string[] {
|
||||
return APP_LOCALES.flatMap((locale) =>
|
||||
ROUTE_IDS.filter((routeId) => routeId !== 'notFound').map((routeId) =>
|
||||
routePath(routeId, locale),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function prerenderableServerPaths(): readonly string[] {
|
||||
return prerenderablePaths().map((path) => (path === '/' ? '' : path.slice(1)));
|
||||
}
|
||||
170
src/app/core/seo/crawl-assets.spec.ts
Normal file
170
src/app/core/seo/crawl-assets.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { CASE_STUDY_IDS } from '../content/content.contracts';
|
||||
import { prerenderablePaths, routePath } from '../routing/route-paths';
|
||||
import { absoluteUrl } from './route-metadata';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'public');
|
||||
const EXCLUDED = new RegExp(
|
||||
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
|
||||
'i',
|
||||
);
|
||||
|
||||
function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string {
|
||||
return readFileSync(join(PUBLIC, name), 'utf8');
|
||||
}
|
||||
|
||||
function expectedCanonicals(): readonly string[] {
|
||||
return prerenderablePaths().map((path) => absoluteUrl(path));
|
||||
}
|
||||
|
||||
function sitemapLocs(xml: string): string[] {
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function sitemapAlternates(xml: string): Array<{ loc: string; hreflang: string; href: string }> {
|
||||
const blocks = xml.split(/<url>/).slice(1);
|
||||
const rows: Array<{ loc: string; hreflang: string; href: string }> = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const loc = block.match(/<loc>([^<]+)<\/loc>/)?.[1];
|
||||
if (!loc) {
|
||||
throw new Error(`sitemap.xml has a <url> block without <loc>: ${block.slice(0, 120)}`);
|
||||
}
|
||||
|
||||
const links = [
|
||||
...block.matchAll(
|
||||
/<xhtml:link[^>]*rel="alternate"[^>]*hreflang="([^"]+)"[^>]*href="([^"]+)"/g,
|
||||
),
|
||||
];
|
||||
|
||||
if (links.length === 0) {
|
||||
throw new Error(`sitemap.xml is missing xhtml alternates for ${loc}`);
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
rows.push({ loc, hreflang: link[1], href: link[2] });
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function siteOriginsIn(text: string): string[] {
|
||||
return [...text.matchAll(/(?:href|loc|Sitemap:\s*)["']?(https?:\/\/[^/\s"'<>]+)/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
}
|
||||
|
||||
describe('crawl assets', () => {
|
||||
it('lists every prerenderable path once and no URL outside that set', () => {
|
||||
const xml = readPublic('sitemap.xml');
|
||||
const expected = [...expectedCanonicals()].sort();
|
||||
const actual = [...new Set(sitemapLocs(xml))].sort();
|
||||
|
||||
expect(
|
||||
actual,
|
||||
'sitemap.xml locs must match SITE_CONFIG.siteOrigin + prerenderablePaths()',
|
||||
).toEqual(expected);
|
||||
expect(sitemapLocs(xml)).toHaveLength(expected.length);
|
||||
});
|
||||
|
||||
it('keeps reciprocal hreflang alternates for de-DE, en and x-default', () => {
|
||||
const xml = readPublic('sitemap.xml');
|
||||
const required = ['de-DE', 'en', 'x-default'] as const;
|
||||
|
||||
for (const loc of sitemapLocs(xml)) {
|
||||
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
|
||||
const langs = links.map((row) => row.hreflang).sort();
|
||||
expect(langs, `${loc} must have de-DE, en and x-default`).toEqual([...required].sort());
|
||||
|
||||
const byLang = Object.fromEntries(links.map((row) => [row.hreflang, row.href]));
|
||||
expect(byLang['x-default'], `${loc} x-default must be the German URL`).toBe(byLang['de-DE']);
|
||||
}
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const path of prerenderablePaths().filter((entry) =>
|
||||
locale === 'en'
|
||||
? entry === '/en' || entry.startsWith('/en/')
|
||||
: !(entry === '/en' || entry.startsWith('/en/')),
|
||||
)) {
|
||||
const loc = absoluteUrl(path);
|
||||
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
|
||||
const germanHref = links.find((row) => row.hreflang === 'de-DE')?.href;
|
||||
const englishHref = links.find((row) => row.hreflang === 'en')?.href;
|
||||
expect(germanHref, `${loc} is missing a de-DE alternate`).toBeTruthy();
|
||||
expect(englishHref, `${loc} is missing an en alternate`).toBeTruthy();
|
||||
|
||||
const germanLinks = sitemapAlternates(xml).filter((row) => row.loc === germanHref);
|
||||
const englishLinks = sitemapAlternates(xml).filter((row) => row.loc === englishHref);
|
||||
expect(
|
||||
germanLinks.find((row) => row.hreflang === 'en')?.href,
|
||||
`${germanHref} must point back to ${englishHref}`,
|
||||
).toBe(englishHref);
|
||||
expect(
|
||||
englishLinks.find((row) => row.hreflang === 'de-DE')?.href,
|
||||
`${englishHref} must point back to ${germanHref}`,
|
||||
).toBe(germanHref);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('lists the required canonical URLs in both locales in llms.txt', () => {
|
||||
const text = readPublic('llms.txt');
|
||||
const required = [
|
||||
absoluteUrl(routePath('home', 'de')),
|
||||
absoluteUrl(routePath('home', 'en')),
|
||||
absoluteUrl(routePath('pitch', 'de')),
|
||||
absoluteUrl(routePath('pitch', 'en')),
|
||||
absoluteUrl(routePath('about', 'de')),
|
||||
absoluteUrl(routePath('about', 'en')),
|
||||
absoluteUrl(routePath('services', 'de')),
|
||||
absoluteUrl(routePath('services', 'en')),
|
||||
absoluteUrl(routePath('projects', 'de')),
|
||||
absoluteUrl(routePath('projects', 'en')),
|
||||
...CASE_STUDY_IDS.flatMap((id) => [
|
||||
`${absoluteUrl(routePath('projects', 'de'))}#${id}`,
|
||||
`${absoluteUrl(routePath('projects', 'en'))}#${id}`,
|
||||
]),
|
||||
absoluteUrl(routePath('contact', 'de')),
|
||||
absoluteUrl(routePath('contact', 'en')),
|
||||
absoluteUrl(routePath('stack', 'de')),
|
||||
absoluteUrl(routePath('stack', 'en')),
|
||||
absoluteUrl(routePath('imprint', 'de')),
|
||||
absoluteUrl(routePath('imprint', 'en')),
|
||||
absoluteUrl(routePath('privacy', 'de')),
|
||||
absoluteUrl(routePath('privacy', 'en')),
|
||||
];
|
||||
|
||||
for (const url of required) {
|
||||
expect(text.includes(url), `llms.txt is missing ${url}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('points robots.txt at the sitemap on the canonical origin', () => {
|
||||
const robots = readPublic('robots.txt');
|
||||
expect(robots, 'robots.txt must reference the sitemap at SITE_CONFIG.siteOrigin').toContain(
|
||||
`Sitemap: ${SITE_CONFIG.siteOrigin}/sitemap.xml`,
|
||||
);
|
||||
expect(robots).toMatch(/User-agent:\s*\*/);
|
||||
expect(robots).not.toMatch(/Disallow:\s+\S+/);
|
||||
});
|
||||
|
||||
it('uses only SITE_CONFIG.siteOrigin and never names excluded stations', () => {
|
||||
for (const name of ['robots.txt', 'sitemap.xml', 'llms.txt'] as const) {
|
||||
const text = readPublic(name);
|
||||
const origins = siteOriginsIn(text);
|
||||
const unexpected = origins.filter((origin) => origin !== SITE_CONFIG.siteOrigin);
|
||||
expect(unexpected, `${name} contains an origin other than ${SITE_CONFIG.siteOrigin}`).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(EXCLUDED.test(text), `${name} contains an excluded station name`).toBe(false);
|
||||
expect(text).not.toContain('/leistungen/software');
|
||||
expect(text).not.toContain('/en/services/software');
|
||||
expect(text).not.toContain('/leistungen/ai-integration');
|
||||
expect(text).not.toContain('/en/services/ai-integration');
|
||||
}
|
||||
});
|
||||
});
|
||||
69
src/app/core/seo/route-metadata.spec.ts
Normal file
69
src/app/core/seo/route-metadata.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS } from '../routing/route-ids';
|
||||
import { routePath } from '../routing/route-paths';
|
||||
import { buildRouteMetadata, OG_LOCALE } from './route-metadata';
|
||||
|
||||
describe('buildRouteMetadata', () => {
|
||||
it('derives title, description, canonical, alternates, Open Graph and Twitter for every route', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
const page = SITE_CONTENT_DATA[locale].pages[routeId];
|
||||
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
|
||||
const other = locale === 'de' ? 'en' : 'de';
|
||||
|
||||
expect(metadata.title, `${locale}.${routeId} title`).toBe(page.title);
|
||||
expect(metadata.description, `${locale}.${routeId} description`).toBe(page.description);
|
||||
expect(metadata.openGraph.type).toBe('website');
|
||||
expect(metadata.openGraph.title).toBe(page.title);
|
||||
expect(metadata.openGraph.description).toBe(page.description);
|
||||
expect(metadata.openGraph.siteName).toBe(SITE_CONFIG.personName);
|
||||
expect(metadata.openGraph.locale).toBe(OG_LOCALE[locale]);
|
||||
expect(metadata.openGraph.localeAlternate).toBe(OG_LOCALE[other]);
|
||||
expect(metadata.twitter.card).toBe('summary');
|
||||
expect(metadata.twitter.title).toBe(page.title);
|
||||
expect(metadata.twitter.description).toBe(page.description);
|
||||
|
||||
if (routeId === 'notFound') {
|
||||
expect(metadata.canonical, `${locale}.notFound must have no canonical`).toBeNull();
|
||||
expect(metadata.alternates, `${locale}.notFound must have no alternates`).toEqual([]);
|
||||
expect(metadata.openGraph.url).toBeNull();
|
||||
expect(metadata.robots).toBe('noindex, follow');
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedCanonical = `${SITE_CONFIG.siteOrigin}${routePath(routeId, locale)}`;
|
||||
expect(metadata.canonical, `${locale}.${routeId} canonical`).toBe(expectedCanonical);
|
||||
expect(metadata.openGraph.url).toBe(expectedCanonical);
|
||||
expect(metadata.robots).toBe('index, follow');
|
||||
|
||||
const byLang = Object.fromEntries(
|
||||
metadata.alternates.map((entry) => [entry.hreflang, entry.href]),
|
||||
);
|
||||
expect(Object.keys(byLang).sort()).toEqual(['de-DE', 'en', 'x-default']);
|
||||
expect(byLang['de-DE']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'de')}`);
|
||||
expect(byLang['en']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'en')}`);
|
||||
expect(byLang['x-default']).toBe(byLang['de-DE']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps German and English alternates reciprocal for every public route', () => {
|
||||
for (const routeId of ROUTE_IDS.filter((id) => id !== 'notFound')) {
|
||||
const german = buildRouteMetadata(routeId, 'de', SITE_CONTENT_DATA);
|
||||
const english = buildRouteMetadata(routeId, 'en', SITE_CONTENT_DATA);
|
||||
const germanHref = german.alternates.find((entry) => entry.hreflang === 'de-DE')?.href;
|
||||
const englishHref = german.alternates.find((entry) => entry.hreflang === 'en')?.href;
|
||||
|
||||
expect(english.alternates.find((entry) => entry.hreflang === 'de-DE')?.href).toBe(germanHref);
|
||||
expect(english.alternates.find((entry) => entry.hreflang === 'en')?.href).toBe(englishHref);
|
||||
expect(german.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
|
||||
germanHref,
|
||||
);
|
||||
expect(english.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
|
||||
germanHref,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
61
src/app/core/seo/route-metadata.ts
Normal file
61
src/app/core/seo/route-metadata.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type SiteContent } from '../content/content.contracts';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { otherLocale } from '../i18n/locale';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
import { routePath } from '../routing/route-paths';
|
||||
import { type RouteMetadata } from './seo.contracts';
|
||||
|
||||
export const OG_LOCALE: Record<AppLocale, string> = {
|
||||
de: 'de_DE',
|
||||
en: 'en_US',
|
||||
};
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return `${SITE_CONFIG.siteOrigin}${path === '/' ? '/' : path}`;
|
||||
}
|
||||
|
||||
export function canonicalUrl(routeId: RouteId, locale: AppLocale): string {
|
||||
return absoluteUrl(routePath(routeId, locale));
|
||||
}
|
||||
|
||||
export function buildRouteMetadata(
|
||||
routeId: RouteId,
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): RouteMetadata {
|
||||
const page = content[locale].pages[routeId];
|
||||
const title = page.title;
|
||||
const description = page.description;
|
||||
const isNotFound = routeId === 'notFound';
|
||||
const canonical = isNotFound ? null : canonicalUrl(routeId, locale);
|
||||
const alternateLocale = otherLocale(locale);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
alternates: isNotFound
|
||||
? []
|
||||
: [
|
||||
{ hreflang: 'de-DE', href: canonicalUrl(routeId, 'de') },
|
||||
{ hreflang: 'en', href: canonicalUrl(routeId, 'en') },
|
||||
{ hreflang: 'x-default', href: canonicalUrl(routeId, 'de') },
|
||||
],
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title,
|
||||
description,
|
||||
url: canonical,
|
||||
siteName: SITE_CONFIG.personName,
|
||||
locale: OG_LOCALE[locale],
|
||||
localeAlternate: OG_LOCALE[alternateLocale],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
robots: isNotFound ? 'noindex, follow' : 'index, follow',
|
||||
};
|
||||
}
|
||||
30
src/app/core/seo/seo-title.strategy.ts
Normal file
30
src/app/core/seo/seo-title.strategy.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { isAppRouteData } from '../routing/app-route-data';
|
||||
import { buildRouteMetadata } from './route-metadata';
|
||||
import { SeoService } from './seo.service';
|
||||
|
||||
@Injectable()
|
||||
export class SeoTitleStrategy extends TitleStrategy {
|
||||
private readonly title = inject(Title);
|
||||
private readonly seo = inject(SeoService);
|
||||
private readonly content = inject(SITE_CONTENT);
|
||||
|
||||
override updateTitle(snapshot: RouterStateSnapshot): void {
|
||||
let current = snapshot.root;
|
||||
|
||||
while (current.firstChild) {
|
||||
current = current.firstChild;
|
||||
}
|
||||
|
||||
if (!isAppRouteData(current.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = buildRouteMetadata(current.data.routeId, current.data.locale, this.content);
|
||||
this.title.setTitle(metadata.title);
|
||||
this.seo.apply(current.data.routeId, current.data.locale);
|
||||
}
|
||||
}
|
||||
30
src/app/core/seo/seo.contracts.ts
Normal file
30
src/app/core/seo/seo.contracts.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface RouteAlternate {
|
||||
readonly hreflang: string;
|
||||
readonly href: string;
|
||||
}
|
||||
|
||||
export interface OpenGraphMetadata {
|
||||
readonly type: 'website';
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly url: string | null;
|
||||
readonly siteName: string;
|
||||
readonly locale: string;
|
||||
readonly localeAlternate: string;
|
||||
}
|
||||
|
||||
export interface TwitterMetadata {
|
||||
readonly card: 'summary';
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface RouteMetadata {
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly canonical: string | null;
|
||||
readonly alternates: readonly RouteAlternate[];
|
||||
readonly openGraph: OpenGraphMetadata;
|
||||
readonly twitter: TwitterMetadata;
|
||||
readonly robots: string;
|
||||
}
|
||||
85
src/app/core/seo/seo.service.spec.ts
Normal file
85
src/app/core/seo/seo.service.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { routePath } from '../routing/route-paths';
|
||||
import { SeoService } from './seo.service';
|
||||
|
||||
describe('SeoService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SeoService, { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll('[data-seo]').forEach((node) => node.remove());
|
||||
document.head
|
||||
.querySelectorAll(
|
||||
'meta[name="description"], meta[name="robots"], meta[name^="twitter:"], meta[property^="og:"]',
|
||||
)
|
||||
.forEach((node) => node.remove());
|
||||
});
|
||||
|
||||
it('applies two routes in sequence without duplicating owned head elements', () => {
|
||||
const seo = TestBed.inject(SeoService);
|
||||
const title = TestBed.inject(Title);
|
||||
|
||||
seo.apply('home', 'de');
|
||||
seo.apply('projects', 'en');
|
||||
|
||||
expect(title.getTitle()).toBe(SITE_CONTENT_DATA.en.pages.projects.title);
|
||||
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(3);
|
||||
expect(document.querySelectorAll('meta[name="description"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('meta[name="robots"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
|
||||
|
||||
const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href');
|
||||
expect(canonical).toBe(`${SITE_CONFIG.siteOrigin}${routePath('projects', 'en')}`);
|
||||
expect(document.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
|
||||
SITE_CONTENT_DATA.en.pages.projects.description,
|
||||
);
|
||||
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
|
||||
'index, follow',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops canonical and alternate links for notFound and sets noindex', () => {
|
||||
const seo = TestBed.inject(SeoService);
|
||||
seo.apply('home', 'de');
|
||||
seo.apply('notFound', 'de');
|
||||
|
||||
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(0);
|
||||
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(0);
|
||||
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
|
||||
'noindex, follow',
|
||||
);
|
||||
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('writes pitch metadata, canonical and reciprocal hreflang', () => {
|
||||
const seo = TestBed.inject(SeoService);
|
||||
seo.apply('pitch', 'de');
|
||||
|
||||
expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe(
|
||||
`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`,
|
||||
);
|
||||
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
|
||||
'index, follow',
|
||||
);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="de-DE"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="en"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'en')}`);
|
||||
expect(
|
||||
document.querySelector('link[rel="alternate"][hreflang="x-default"]')?.getAttribute('href'),
|
||||
).toBe(`${SITE_CONFIG.siteOrigin}${routePath('pitch', 'de')}`);
|
||||
expect(document.querySelector('script[type="application/ld+json"]')?.textContent).toContain(
|
||||
'WebPage',
|
||||
);
|
||||
});
|
||||
});
|
||||
116
src/app/core/seo/seo.service.ts
Normal file
116
src/app/core/seo/seo.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
import { buildRouteMetadata } from './route-metadata';
|
||||
import { type RouteMetadata } from './seo.contracts';
|
||||
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
|
||||
|
||||
const SEO_OWNED = 'data-seo';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SeoService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly title = inject(Title);
|
||||
private readonly meta = inject(Meta);
|
||||
private readonly content = inject(SITE_CONTENT);
|
||||
|
||||
apply(routeId: RouteId, locale: AppLocale): void {
|
||||
const metadata = buildRouteMetadata(routeId, locale, this.content);
|
||||
this.title.setTitle(metadata.title);
|
||||
this.updateMeta(metadata);
|
||||
this.replaceOwnedElements(routeId, locale, metadata);
|
||||
}
|
||||
|
||||
private updateMeta(metadata: RouteMetadata): void {
|
||||
this.meta.updateTag(
|
||||
{ name: 'description', content: metadata.description },
|
||||
'name="description"',
|
||||
);
|
||||
this.meta.updateTag({ name: 'robots', content: metadata.robots }, 'name="robots"');
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:type', content: metadata.openGraph.type },
|
||||
'property="og:type"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:title', content: metadata.openGraph.title },
|
||||
'property="og:title"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:description', content: metadata.openGraph.description },
|
||||
'property="og:description"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:site_name', content: metadata.openGraph.siteName },
|
||||
'property="og:site_name"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:locale', content: metadata.openGraph.locale },
|
||||
'property="og:locale"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:locale:alternate', content: metadata.openGraph.localeAlternate },
|
||||
'property="og:locale:alternate"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ name: 'twitter:card', content: metadata.twitter.card },
|
||||
'name="twitter:card"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ name: 'twitter:title', content: metadata.twitter.title },
|
||||
'name="twitter:title"',
|
||||
);
|
||||
this.meta.updateTag(
|
||||
{ name: 'twitter:description', content: metadata.twitter.description },
|
||||
'name="twitter:description"',
|
||||
);
|
||||
|
||||
if (metadata.openGraph.url) {
|
||||
this.meta.updateTag(
|
||||
{ property: 'og:url', content: metadata.openGraph.url },
|
||||
'property="og:url"',
|
||||
);
|
||||
} else {
|
||||
this.meta.removeTag('property="og:url"');
|
||||
}
|
||||
}
|
||||
|
||||
private replaceOwnedElements(routeId: RouteId, locale: AppLocale, metadata: RouteMetadata): void {
|
||||
const head = this.document.head;
|
||||
|
||||
for (const owned of Array.from(head.querySelectorAll(`[${SEO_OWNED}]`))) {
|
||||
owned.remove();
|
||||
}
|
||||
|
||||
if (metadata.canonical) {
|
||||
this.appendLink(head, { rel: 'canonical', href: metadata.canonical });
|
||||
}
|
||||
|
||||
for (const alternate of metadata.alternates) {
|
||||
this.appendLink(head, {
|
||||
rel: 'alternate',
|
||||
hreflang: alternate.hreflang,
|
||||
href: alternate.href,
|
||||
});
|
||||
}
|
||||
|
||||
const script = this.document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.setAttribute(SEO_OWNED, '');
|
||||
script.textContent = serializeJsonLd(buildJsonLdGraph(routeId, locale, this.content));
|
||||
head.appendChild(script);
|
||||
}
|
||||
|
||||
private appendLink(head: HTMLHeadElement, attributes: Record<string, string>): void {
|
||||
const link = this.document.createElement('link');
|
||||
link.setAttribute(SEO_OWNED, '');
|
||||
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
link.setAttribute(name, value);
|
||||
}
|
||||
|
||||
head.appendChild(link);
|
||||
}
|
||||
}
|
||||
116
src/app/core/seo/structured-data.spec.ts
Normal file
116
src/app/core/seo/structured-data.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
|
||||
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
|
||||
|
||||
const FORBIDDEN_FIELDS = [
|
||||
'aggregateRating',
|
||||
'review',
|
||||
'offers',
|
||||
'price',
|
||||
'priceRange',
|
||||
'worksFor',
|
||||
'employee',
|
||||
'sponsor',
|
||||
'address',
|
||||
'geo',
|
||||
'openingHours',
|
||||
'telephone',
|
||||
'foundingDate',
|
||||
'numberOfEmployees',
|
||||
] as const;
|
||||
|
||||
const SERVICE_ROUTES: readonly RouteId[] = ['services'];
|
||||
|
||||
function collectKeys(value: unknown, keys = new Set<string>()): Set<string> {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectKeys(entry, keys));
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
keys.add(key);
|
||||
collectKeys(child, keys);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function graphTypes(routeId: RouteId, locale: (typeof APP_LOCALES)[number]): string[] {
|
||||
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
|
||||
return graph['@graph'].map((node) => {
|
||||
if (node && typeof node === 'object' && !Array.isArray(node) && '@type' in node) {
|
||||
return String(node['@type']);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
describe('JSON-LD builders', () => {
|
||||
it('emits a schema.org @graph with the expected types per route', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
|
||||
expect(home['@context']).toBe('https://schema.org');
|
||||
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
|
||||
expect(graphTypes('pitch', locale), `${locale}.pitch`).toEqual(['WebPage']);
|
||||
|
||||
expect(graphTypes('services', locale), `${locale}.services`).toEqual([
|
||||
'Service',
|
||||
'Service',
|
||||
'Service',
|
||||
'Service',
|
||||
]);
|
||||
|
||||
expect(graphTypes('projects', locale)).toEqual([
|
||||
'CreativeWork',
|
||||
'CreativeWork',
|
||||
'CreativeWork',
|
||||
'CreativeWork',
|
||||
'CreativeWork',
|
||||
'CreativeWork',
|
||||
]);
|
||||
|
||||
for (const routeId of ROUTE_IDS.filter(
|
||||
(id) => id !== 'home' && id !== 'projects' && !SERVICE_ROUTES.includes(id),
|
||||
)) {
|
||||
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['WebPage']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('escapes <, > and & inside the serialized payload', () => {
|
||||
const serialized = serializeJsonLd({
|
||||
name: 'A <script>alert(1)</script> & more',
|
||||
});
|
||||
|
||||
expect(serialized).toContain('\\u003c');
|
||||
expect(serialized).toContain('\\u003e');
|
||||
expect(serialized).toContain('\\u0026');
|
||||
expect(serialized).not.toContain('<');
|
||||
expect(serialized).not.toContain('>');
|
||||
expect(serialized).not.toContain('&');
|
||||
});
|
||||
|
||||
it('never invents forbidden fields or excluded station names', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const routeId of ROUTE_IDS) {
|
||||
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
|
||||
const keys = collectKeys(graph);
|
||||
for (const field of FORBIDDEN_FIELDS) {
|
||||
expect(keys.has(field), `${locale}.${routeId} contains ${field}`).toBe(false);
|
||||
}
|
||||
|
||||
const serialized = serializeJsonLd(graph);
|
||||
expect(serialized).not.toMatch(
|
||||
new RegExp(
|
||||
`\\b(${['hu' + 'p', 'bit' + 'wiz', 'cyber' + 'trading', 'ara' + 'com', 'my' + 'spa'].join('|')})\\b`,
|
||||
'i',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
143
src/app/core/seo/structured-data.ts
Normal file
143
src/app/core/seo/structured-data.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { CASE_STUDY_IDS, type SiteContent } from '../content/content.contracts';
|
||||
import { SITE_CONFIG } from '../content/site-config';
|
||||
import { LOCALE_HTML_LANG, type AppLocale } from '../i18n/locale';
|
||||
import { type RouteId } from '../routing/route-ids';
|
||||
import { canonicalUrl } from './route-metadata';
|
||||
|
||||
const SCHEMA_CONTEXT = 'https://schema.org';
|
||||
|
||||
const SERVICE_ROUTE_IDS: readonly RouteId[] = ['services'];
|
||||
|
||||
export type JsonLdValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| readonly JsonLdValue[]
|
||||
| { readonly [key: string]: JsonLdValue };
|
||||
|
||||
export interface JsonLdGraph {
|
||||
readonly '@context': typeof SCHEMA_CONTEXT;
|
||||
readonly '@graph': readonly JsonLdValue[];
|
||||
}
|
||||
|
||||
function personNode(locale: AppLocale, content: Record<AppLocale, SiteContent>): JsonLdValue {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: SITE_CONFIG.personName,
|
||||
jobTitle: content[locale].seo.jobTitle,
|
||||
email: SITE_CONFIG.contactEmail,
|
||||
url: canonicalUrl('home', locale),
|
||||
description: content[locale].pages.home.description,
|
||||
};
|
||||
}
|
||||
|
||||
function professionalServiceNode(
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): JsonLdValue {
|
||||
const seo = content[locale].seo;
|
||||
|
||||
return {
|
||||
'@type': 'ProfessionalService',
|
||||
name: seo.professionalServiceName,
|
||||
description: seo.professionalServiceDescription,
|
||||
url: canonicalUrl('home', locale),
|
||||
provider: {
|
||||
'@type': 'Person',
|
||||
name: SITE_CONFIG.personName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function serviceNodes(
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): readonly JsonLdValue[] {
|
||||
const page = content[locale].services;
|
||||
const servicesUrl = canonicalUrl('services', locale);
|
||||
|
||||
return page.serviceSections.map((section) => ({
|
||||
'@type': 'Service',
|
||||
name: section.title,
|
||||
description: section.lead,
|
||||
url: `${servicesUrl}#${section.id}`,
|
||||
provider: {
|
||||
'@type': 'Person',
|
||||
name: SITE_CONFIG.personName,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function projectWorks(
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): readonly JsonLdValue[] {
|
||||
const projectsUrl = canonicalUrl('projects', locale);
|
||||
|
||||
return CASE_STUDY_IDS.map((caseId) => {
|
||||
const study = content[locale].cases[caseId];
|
||||
|
||||
return {
|
||||
'@type': 'CreativeWork',
|
||||
name: study.headline,
|
||||
description: study.summary,
|
||||
url: `${projectsUrl}#${caseId}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function webPageNode(
|
||||
routeId: RouteId,
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): JsonLdValue {
|
||||
const page = content[locale].pages[routeId];
|
||||
const node: { [key: string]: JsonLdValue } = {
|
||||
'@type': 'WebPage',
|
||||
name: page.title,
|
||||
description: page.description,
|
||||
inLanguage: LOCALE_HTML_LANG[locale],
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: SITE_CONFIG.personName,
|
||||
url: SITE_CONFIG.siteOrigin + '/',
|
||||
},
|
||||
};
|
||||
|
||||
if (routeId !== 'notFound') {
|
||||
node['url'] = canonicalUrl(routeId, locale);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export function buildJsonLdGraph(
|
||||
routeId: RouteId,
|
||||
locale: AppLocale,
|
||||
content: Record<AppLocale, SiteContent>,
|
||||
): JsonLdGraph {
|
||||
let graph: readonly JsonLdValue[];
|
||||
|
||||
if (routeId === 'home') {
|
||||
graph = [personNode(locale, content), professionalServiceNode(locale, content)];
|
||||
} else if (SERVICE_ROUTE_IDS.includes(routeId)) {
|
||||
graph = serviceNodes(locale, content);
|
||||
} else if (routeId === 'projects') {
|
||||
graph = projectWorks(locale, content);
|
||||
} else {
|
||||
graph = [webPageNode(routeId, locale, content)];
|
||||
}
|
||||
|
||||
return {
|
||||
'@context': SCHEMA_CONTEXT,
|
||||
'@graph': graph,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeJsonLd(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026');
|
||||
}
|
||||
8
src/app/features/about/about.html
Normal file
8
src/app/features/about/about.html
Normal file
@@ -0,0 +1,8 @@
|
||||
@if (page(); as copy) {
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
15
src/app/features/about/about.ts
Normal file
15
src/app/features/about/about.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { ContentService } from '../../core/content/content.service';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection],
|
||||
templateUrl: './about.html',
|
||||
styleUrl: '../../shared/page-shell.scss',
|
||||
})
|
||||
export class AboutPage {
|
||||
protected readonly page = inject(ContentService).page('about');
|
||||
}
|
||||
9
src/app/features/contact/contact.html
Normal file
9
src/app/features/contact/contact.html
Normal file
@@ -0,0 +1,9 @@
|
||||
@if (page(); as copy) {
|
||||
<div class="content-container stack page">
|
||||
<app-page-hero [hero]="copy.hero" [ctas]="copy.ctas" />
|
||||
@for (section of copy.sections; track section.id) {
|
||||
<app-content-section [section]="section" />
|
||||
}
|
||||
<app-contact-briefing [copy]="copy" />
|
||||
</div>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user